Compare commits

...

416 Commits

Author SHA1 Message Date
Raphael Michel
2b40c9f44c Bump django-oauth-toolkit to 2.2.* 2022-12-17 20:08:12 +01:00
Raphael Michel
ad1dab3b7f Bank transfer: Fix refund export when plugin is disabled 2022-12-13 18:40:26 +01:00
Raphael Michel
930abe0cc5 Fix crash in gift card view (PRETIXEU-493) 2022-12-13 18:32:22 +01:00
Raphael Michel
ba2cc56c82 Radio collapse elements: Deal with Firefox keeping form state on reload 2022-12-13 10:54:18 +01:00
Raphael Michel
cb1f63bf80 Fix regression in address validation for resellers 2022-12-12 17:21:55 +01:00
Martin Gross
aab7042cda PPv2: Simulate cart_payments in XHR-calls; only look at multi_use-payments for remaining value calculation (#2970)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-12-12 15:35:16 +01:00
Raphael Michel
495a21c683 GitHub actions: Ignore flake8 no longer supporting Python 3.7 (#2971) 2022-12-12 15:29:47 +01:00
Martin Gross
86b5ba6937 PPv2: Actually log dict-representation on value mismatch 2022-12-12 12:44:07 +01:00
Raphael Michel
3d9679a144 Allow variations to override item meta data (#2965) 2022-12-12 12:06:09 +01:00
Raphael Michel
5f899ed5c5 Bump chardet to 5.1.* 2022-12-12 12:03:27 +01:00
Raphael Michel
47dabc1fe7 Bump pytest-xdist to 3.1.* 2022-12-12 10:53:32 +01:00
Raphael Michel
2d7c4a3d42 Translations: Add Croatian 2022-12-12 10:53:09 +01:00
Raphael Michel
51ef98f736 Translations: Add Croatian 2022-12-12 10:53:09 +01:00
Mie Frydensbjerg
2d7d2b1a90 Translations: Update Danish
Currently translated at 71.1% (143 of 201 strings)

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

powered by weblate
2022-12-12 10:53:09 +01:00
Mie Frydensbjerg
cede7ba3aa Translations: Update Danish
Currently translated at 35.6% (1755 of 4919 strings)

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

powered by weblate
2022-12-12 10:53:09 +01:00
Raphael Michel
4fd8726b05 Bump flake8 to 6.0.*, pycodestyle to 2.10.* and pyflakes to 3.0.* 2022-12-12 10:53:01 +01:00
dependabot[bot]
b344ce90ba Bump vue and vue-template-compiler in /src/pretix/static/npm_dir (#2940)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-12 09:25:36 +01:00
dependabot[bot]
69dc7f56e5 Bump @babel/core from 7.19.6 to 7.20.5 in /src/pretix/static/npm_dir (#2941)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-12 09:25:23 +01:00
Raphael Michel
247a61489f XLSX generation: Remove invalid unicode characters 2022-12-09 17:41:33 +01:00
Raphael Michel
979d23e997 Invoice renderer: Unify HTML cleaning and clean intro and additional
text
2022-12-09 17:30:26 +01:00
Raphael Michel
28e529995d Add missing license headers 2022-12-09 13:24:17 +01:00
Raphael Michel
a982cbf6b6 Name field: Improve compatibility with old formats 2022-12-09 10:42:26 +01:00
Raphael Michel
f1c2ae5b6b Revert "Bump pycodestyle to 2.10.*"
This reverts commit dfe3454915.
2022-12-08 14:17:22 +01:00
dependabot[bot]
5b27ac66f9 Bump decode-uri-component from 0.2.0 to 0.2.2 in /src/pretix/static/npm_dir (#2952)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 13:50:47 +01:00
Raphael Michel
c71ac2141f Bump drf_ujson2 to 1.7.* 2022-12-08 13:50:20 +01:00
Raphael Michel
e59498d65d Bump pytest-rerunfailures to 10.* 2022-12-08 13:50:20 +01:00
Raphael Michel
dfe3454915 Bump pycodestyle to 2.10.* 2022-12-08 13:50:20 +01:00
Raphael Michel
b64c5735a8 Make str.format_map with untrusted input safer (#2931) 2022-12-08 13:49:07 +01:00
dependabot[bot]
11eecd739d Bump @rollup/plugin-babel from 6.0.2 to 6.0.3 in /src/pretix/static/npm_dir (#2942)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 13:47:34 +01:00
Raphael Michel
07a6d4898a Fix missing Discount.is_available_by_time method 2022-12-08 10:53:00 +01:00
Raphael Michel
a759e23504 Docs: Add internal_name to digital content api 2022-12-08 10:48:13 +01:00
Richard Schreiber
3eaf05502a Checkout: copy answers from previous item instead of first (#Z23112272) 2022-12-07 09:24:24 +01:00
Raphael Michel
04df1c2032 Introduce country-specific address validation (#2945)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-12-05 12:42:46 +01:00
Raphael Michel
6a8df75a9f Fix regression in handling gift card payments (#2936) 2022-12-05 11:32:27 +01:00
Richard Schreiber
547cfdffd6 PDF editor: Reduce precision size of empty page (Z#23112472) (#2935) 2022-12-01 13:19:21 +01:00
Raphael Michel
f72a0b4c09 Bump version to 4.16.0.dev0 2022-11-30 09:53:57 +01:00
Raphael Michel
3077292d15 Bump version to 4.15.0 2022-11-30 09:53:00 +01:00
Raphael Michel
2c831d5d6e Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4919 of 4919 strings)

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

powered by weblate
2022-11-30 09:43:08 +01:00
Raphael Michel
be8d84be13 Translations: Update German
Currently translated at 100.0% (4919 of 4919 strings)

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

powered by weblate
2022-11-30 09:43:08 +01:00
Raphael Michel
23c497e438 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2022-11-29 21:31:30 +01:00
Vasco Baleia
09643e47b9 Translations: Update Portuguese (Portugal)
Currently translated at 85.9% (4228 of 4917 strings)

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

powered by weblate
2022-11-29 21:29:10 +01:00
tlm06
1ef922cf56 Translations: Update Portuguese (Portugal)
Currently translated at 84.3% (4149 of 4917 strings)

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

powered by weblate
2022-11-29 21:29:10 +01:00
Martin Gross
b12ab02e89 BasePP: Do not render "None" if no messages are available for {payment_info} 2022-11-29 16:11:14 +01:00
Raphael Michel
cce98e0418 Docs: Remove long-obsolete change notes 2022-11-29 14:29:42 +01:00
Raphael Michel
b8dd30b6dd Don't show "no products" if voucher allows seating plan 2022-11-29 10:44:03 +01:00
ser8phin
ea9a96e124 PDF editor: Fix scaling with browser zoom (Z#23112370) (#2929) 2022-11-28 13:54:55 +01:00
Raphael Michel
b72dc0ce8e API: Allow to whiteliste fields for the orders resource 2022-11-28 10:57:12 +01:00
Raphael Michel
0a30fa70da Fix bug in 8f94d1447 2022-11-28 10:21:41 +01:00
Raphael Michel
add240a7b9 Fix linking of orders to customers if email is null 2022-11-28 10:00:33 +01:00
Raphael Michel
0b97198cff Fix crash in question answer validation 2022-11-25 13:11:29 +01:00
Raphael Michel
8f94d14479 API: Fix validation of country field inputs 2022-11-25 13:11:17 +01:00
Raphael Michel
0919d5dbca Fix regression in PayPal payments 2022-11-25 11:29:19 +01:00
Raphael Michel
ff153164f8 API: Add search parameter for subevents 2022-11-24 17:58:18 +01:00
Raphael Michel
b8e3d6c71d Fix line breaks in german translation 2022-11-24 17:42:54 +01:00
Raphael Michel
f782324d5f Allow to adjust name and description of gift card payments 2022-11-24 16:36:24 +01:00
Raphael Michel
5259c8f33e Fix URL conflict 2022-11-24 14:55:17 +01:00
Raphael Michel
079b72391c Commit missing files 2022-11-24 13:56:54 +01:00
Raphael Michel
e9ba9a25df Allow to download tickets with alternative layouts in backend 2022-11-24 13:44:46 +01:00
Raphael Michel
5858ed8d5c Fix use of shadowed variable name 2022-11-23 21:43:05 +01:00
dependabot[bot]
0b0ecf22bf Bump django-formtools from 2.3 to 2.4 in /src (#2839)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2022-11-23 16:11:35 +01:00
Bentrex95
3b1cd8e659 Waiting list: Allow transfer to other subevent (#2811)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
2022-11-23 16:11:23 +01:00
Raphael Michel
5e66809c7b Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4909 of 4909 strings)

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

powered by weblate
2022-11-23 15:51:38 +01:00
Raphael Michel
c39328dd2a Translations: Update German
Currently translated at 100.0% (4909 of 4909 strings)

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

powered by weblate
2022-11-23 15:51:38 +01:00
Raphael Michel
70ccd2fbe4 Bump django-bootstrap3 to 22.2.* 2022-11-23 15:45:56 +01:00
Raphael Michel
8c8e8031fc Bump stripe to 5.0.* 2022-11-23 15:45:56 +01:00
Richard Schreiber
355b16e8e5 Order list export: Add event meta data (Z#2397902) (#2906) 2022-11-23 15:34:28 +01:00
Raphael Michel
09c316ccba Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-11-23 14:56:36 +01:00
Raphael Michel
a1075840c6 Thumbnails: Store creation date (#2920) 2022-11-23 14:56:05 +01:00
tlm06
b1a3ececad Translations: Update Portuguese (Portugal)
Currently translated at 84.7% (4155 of 4905 strings)

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

powered by weblate
2022-11-23 14:55:45 +01:00
Raphael Michel
9624b1c505 Support for external gift cards (#2912) 2022-11-23 14:52:56 +01:00
Raphael Michel
d3589696d7 Sendmail: Allow scheduled mails to recover from "missed" 2022-11-22 12:29:01 +01:00
0xflotus
9523291651 chore: fix small typo error (#2921) 2022-11-22 08:14:12 +01:00
Raphael Michel
b539f5e2f2 Fix image size validation in product form 2022-11-21 18:17:38 +01:00
Martin Gross
a18eb3be70 Plugins: Fix check if a restricted plugin is really restricted 2022-11-21 16:25:34 +01:00
Raphael Michel
ac59bbff5d Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4902 of 4902 strings)

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

powered by weblate
2022-11-21 16:17:07 +01:00
Raphael Michel
69f3e938f2 Translations: Update German
Currently translated at 100.0% (4902 of 4902 strings)

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

powered by weblate
2022-11-21 16:17:07 +01:00
Raphael Michel
a0c1903ce5 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4902 of 4902 strings)

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

powered by weblate
2022-11-21 16:17:07 +01:00
Raphael Michel
3c8b188352 Translations: Update German
Currently translated at 100.0% (4902 of 4902 strings)

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

powered by weblate
2022-11-21 16:17:07 +01:00
tlm06
76e3b39f8f Translations: Update Portuguese (Portugal)
Currently translated at 84.9% (4151 of 4888 strings)

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

powered by weblate
2022-11-21 16:17:07 +01:00
Raphael Michel
662e2cd116 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-11-21 15:52:18 +01:00
tlm06
eeaa3bc2a9 Translations: Update Portuguese (Portugal)
Currently translated at 84.9% (4150 of 4888 strings)

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

powered by weblate
2022-11-21 15:48:18 +01:00
David Vaz
bbe8247606 Translations: Update Portuguese (Portugal)
Currently translated at 84.9% (4150 of 4888 strings)

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

powered by weblate
2022-11-21 15:48:18 +01:00
tlm06
5c46c1d14f Translations: Update Portuguese (Portugal)
Currently translated at 84.2% (4119 of 4888 strings)

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

powered by weblate
2022-11-21 15:48:18 +01:00
David Vaz
651b676cfc Translations: Update Portuguese (Portugal)
Currently translated at 84.2% (4119 of 4888 strings)

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

powered by weblate
2022-11-21 15:48:18 +01:00
Raphael Michel
5ee62c551e Group identical lines on invoice PDF (#2918) 2022-11-21 15:47:57 +01:00
Raphael Michel
50e79b51de Customer login: Don't chain next= calls to login page 2022-11-20 14:46:32 +01:00
Raphael Michel
6e24c20a7a Fix edge case in bundle price configuration 2022-11-20 14:20:40 +01:00
Raphael Michel
481a242054 GitHub actions: Fix missed package upgrade 2022-11-20 13:05:55 +01:00
Raphael Michel
f923c2fed0 Fix price calculation of included add-ons in expired carts 2022-11-18 17:24:02 +01:00
Raphael Michel
228448b00f Bump libsass to 0.22 2022-11-18 16:45:29 +01:00
Raphael Michel
603345762a Bump sepaxml to 2.6.* 2022-11-18 16:45:29 +01:00
tlm06
1812a23860 Translations: Update Portuguese (Portugal)
Currently translated at 83.3% (4076 of 4888 strings)

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

powered by weblate
2022-11-18 16:44:07 +01:00
David Vaz
45374d0c94 Translations: Update Portuguese (Portugal)
Currently translated at 83.0% (4061 of 4888 strings)

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

powered by weblate
2022-11-18 16:44:07 +01:00
tlm06
c5f823596e Translations: Update Portuguese (Portugal)
Currently translated at 83.0% (4061 of 4888 strings)

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

powered by weblate
2022-11-18 16:44:07 +01:00
David Vaz
eebb0a3527 Translations: Update Portuguese (Portugal)
Currently translated at 83.0% (4061 of 4888 strings)

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

powered by weblate
2022-11-18 16:44:07 +01:00
tlm06
bac1e8faf6 Translations: Update Portuguese (Portugal)
Currently translated at 82.7% (4046 of 4888 strings)

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

powered by weblate
2022-11-18 16:44:07 +01:00
tlm06
5cf7654099 Translations: Update Portuguese (Portugal)
Currently translated at 81.5% (3987 of 4888 strings)

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

powered by weblate
2022-11-18 16:44:07 +01:00
Alex
988ef53972 GitHub Actions: Security hardening (#2882) 2022-11-18 16:32:05 +01:00
Raphael Michel
36d20a45dd Sendmail: Fix inconsistent handling of addons and checkins (#2914) 2022-11-18 14:20:43 +01:00
Raphael Michel
0691af7aa4 GitHub Actions: Pin ubuntu version and fix package versions (#2915) 2022-11-18 13:32:35 +01:00
Raphael Michel
6b5436b71a GitHub Actions: Don't rely on specific MariaDB client version 2022-11-18 13:08:38 +01:00
Raphael Michel
a06a693c5c Widget: Fix markup for voucher explanation text 2022-11-17 18:29:15 +01:00
Raphael Michel
7b58ddbfde Don't use Django's redirect() for user-supplied paths 2022-11-17 11:46:03 +01:00
Raphael Michel
f18fb02d0b Fix tests and docs for 62a6a1183 2022-11-16 17:18:54 +01:00
Raphael Michel
3a185b1cbc Bump django-formset-js-improved to 0.5.0.3 2022-11-16 17:17:09 +01:00
Raphael Michel
ba2a9fbd93 Bump arabic-reshaper to 2.1.4 2022-11-16 17:17:09 +01:00
Raphael Michel
a337cf8efa Fix rare crash in MembershipStep 2022-11-16 17:17:09 +01:00
David Vaz
616cc42b9c Translations: Update Portuguese (Portugal)
Currently translated at 64.1% (129 of 201 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/pt_PT/

powered by weblate
2022-11-16 17:17:03 +01:00
David Vaz
08012c42f2 Translations: Update Portuguese (Portugal)
Currently translated at 80.8% (3954 of 4888 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
David Vaz
08368684b0 Translations: Update Portuguese (Portugal)
Currently translated at 63.6% (128 of 201 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/pt_PT/

powered by weblate
2022-11-16 17:17:03 +01:00
David Vaz
17200df0cd Translations: Update Portuguese (Portugal)
Currently translated at 80.4% (3933 of 4888 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
tlm06
28d1bedfc4 Translations: Update Portuguese (Portugal)
Currently translated at 80.4% (3933 of 4888 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
tlm06
af90db9d1e Translations: Update Portuguese (Portugal)
Currently translated at 79.1% (3867 of 4888 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
David Vaz
19c4089da9 Translations: Update Portuguese (Portugal)
Currently translated at 78.9% (3859 of 4888 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
Alexander Mohan Morzeria-Davis
71723935e1 Translations: Update French
Currently translated at 47.0% (2300 of 4888 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
David Vaz
e2ad8f2f74 Translations: Update Portuguese (Portugal)
Currently translated at 63.6% (128 of 201 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/pt_PT/

powered by weblate
2022-11-16 17:17:03 +01:00
David Vaz
f8580a2789 Translations: Update Portuguese (Portugal)
Currently translated at 78.5% (3840 of 4888 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
Raphael Michel
cfeaa502a3 Translations: Update German (informal) (de_Informal)
Currently translated at 99.6% (4873 of 4888 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
Raphael Michel
0ee8d6e9c3 Translations: Update German
Currently translated at 99.6% (4889 of 4904 strings)

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

powered by weblate
2022-11-16 17:17:03 +01:00
Raphael Michel
a0e5717f7d Allow to disable filter support for meta properties (#2901) 2022-11-16 17:12:37 +01:00
Raphael Michel
49097037da PPv2: Improve displaying errors 2022-11-16 11:50:29 +01:00
Raphael Michel
62a6a11836 Add refund details to API 2022-11-15 18:10:19 +01:00
Raphael Michel
3d82058269 Do not show internal name in cart tooltips 2022-11-15 09:55:53 +01:00
Richard Schreiber
4f21bf8001 Calendar: add label „continued“ to event’s title 2022-11-15 08:19:41 +01:00
Raphael Michel
e32e7e2a50 Add clever handling of plus button in cart with voucher (#2893) 2022-11-14 16:55:39 +01:00
Raphael Michel
5b8228bea0 PPv2: Improve error handling (#2899) 2022-11-14 16:55:30 +01:00
Raphael Michel
a628f605a6 Send refund webhooks correctly when refunds are created via API 2022-11-14 12:23:49 +01:00
Martin Gross
e658744f67 PPv2: Do not PATCH custom_id and description for APMs (#2898) 2022-11-14 11:46:35 +01:00
Raphael Michel
776c5e9fa2 Set autocomplete="one-time-code" on TOTP field 2022-11-14 10:37:00 +01:00
Raphael Michel
46b5055aec Bump zeep to 4.2.* 2022-11-11 17:01:35 +01:00
Raphael Michel
ef227deb2e Bump phonenumberslite to 8.13.* 2022-11-11 17:00:32 +01:00
Raphael Michel
30cfe1ef3c Bump pytest-xdist to 3.0.* 2022-11-11 16:59:46 +01:00
Raphael Michel
4d5c828e2a PDF editor: Update pdfjs from 1.7 to 3.0.279 2022-11-11 16:58:58 +01:00
Raphael Michel
f509306b35 PDF editor: Fix browser detection 2022-11-11 16:58:58 +01:00
Richard Schreiber
706e479cff Update vue to 2.7.14 (#2897) 2022-11-11 14:26:16 +01:00
Martin Gross
a5be7dcff5 PayPal2: Allow all https-pages to be framed, addressing CSP+popover issues (Z#23111577) 2022-11-10 19:10:03 +01:00
Raphael Michel
845b3a866b Fix switching from SMTP to custom email 2022-11-10 17:38:05 +01:00
Raphael Michel
91e1e079e1 Allow private SMTP servers by default in debug version 2022-11-10 17:38:03 +01:00
Raphael Michel
9075c75a93 Fix test for exception type 2022-11-10 14:59:22 +01:00
Raphael Michel
7b97204f2f Port b9feceba (Do not show a price if there are mandatory non-free addons) to voucher redemption 2022-11-10 14:48:55 +01:00
Raphael Michel
dfedf09656 PDF renderer: Normalize unicode before printing text 2022-11-10 13:53:15 +01:00
Raphael Michel
655cfe0afd Bump django-redis to 5.2.* 2022-11-10 09:17:26 +01:00
Raphael Michel
faf17f824e Bump django-hijack to 3.2.* 2022-11-10 09:17:26 +01:00
Raphael Michel
fbf52a5219 Bump Pillow to 9.3.* 2022-11-10 09:17:26 +01:00
Richard Schreiber
9466c57c35 Translations: Update Greek
Currently translated at 58.2% (2847 of 4888 strings)

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

powered by weblate
2022-11-10 09:17:11 +01:00
exbu
806ef8477b Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 82.6% (4040 of 4888 strings)

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

powered by weblate
2022-11-10 09:17:11 +01:00
Raphael Michel
7cb654706a Translations: Update German (informal) (de_Informal)
Currently translated at 99.6% (4873 of 4888 strings)

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

powered by weblate
2022-11-10 09:17:11 +01:00
Raphael Michel
dea448e0f8 Translations: Update German
Currently translated at 99.6% (4889 of 4904 strings)

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

powered by weblate
2022-11-10 09:17:11 +01:00
Dennis Lichtenthäler
98b413249a Translations: Update German
Currently translated at 100.0% (4889 of 4889 strings)

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

powered by weblate
2022-11-10 09:17:11 +01:00
Raphael Michel
4630c1fe8b Allow to charge a cancellation fee on unpaid orders (#2845) 2022-11-10 09:11:43 +01:00
Raphael Michel
bb718375e9 Stripe: Allow to set a custom statement descriptor suffix (#2883) 2022-11-10 09:11:35 +01:00
Martin Gross
7d2dd722bd PayPal: Fix loading of Smart Payment Buttons on APM payment page (regression introduced in #2875) 2022-11-09 15:04:35 +01:00
Raphael Michel
2adbd3cd4a Fix isort complain 2022-11-08 18:24:33 +01:00
Raphael Michel
fb483ad00e Add comment to test 2022-11-08 18:07:50 +01:00
Raphael Michel
9cef65f359 API: Fix carts with addons/bundles not being created correctly 2022-11-08 18:03:16 +01:00
Raphael Michel
ceeb69856b API: Support is_bundled during order creation 2022-11-08 16:55:35 +01:00
Raphael Michel
c184187e59 Improve error handling for CSV parsing in voucher bulk creation 2022-11-08 10:27:31 +01:00
Raphael Michel
8ca38bdbaf Badges: Use ExportError instead of OrderError 2022-11-08 10:24:56 +01:00
Raphael Michel
3ae42b0c57 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-11-07 16:22:46 +01:00
Raphael Michel
6368954ecb Translations: Update Ukrainian
Currently translated at 74.1% (3615 of 4878 strings)

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

powered by weblate
2022-11-07 16:22:13 +01:00
Fazenda Dengo
26ebdb7113 Translations: Update Portuguese
Currently translated at 3.8% (187 of 4878 strings)

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

powered by weblate
2022-11-07 16:22:13 +01:00
Fazenda Dengo
a1cb0b386b Translations: Update Portuguese (Portugal)
Currently translated at 63.1% (127 of 201 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/pt_PT/

powered by weblate
2022-11-07 16:22:13 +01:00
Fazenda Dengo
d46e1aba52 Translations: Update Portuguese (Portugal)
Currently translated at 76.6% (3737 of 4878 strings)

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

powered by weblate
2022-11-07 16:22:13 +01:00
Dennis Lichtenthäler
1f41184f9e Translations: Update German
Currently translated at 100.0% (4878 of 4878 strings)

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

powered by weblate
2022-11-07 16:22:13 +01:00
dependabot[bot]
2c746dffb2 Bump @babel/preset-env from 7.19.3 to 7.20.2 in /src/pretix/static/npm_dir (#2886)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-07 16:06:25 +01:00
dependabot[bot]
84bd4e0e94 Bump @rollup/plugin-node-resolve from 14.1.0 to 15.0.1 in /src/pretix/static/npm_dir (#2877)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-07 16:06:12 +01:00
Raphael Michel
93f8b38745 SMTP settings: Don't replace password with ***** 2022-11-07 16:05:33 +01:00
Raphael Michel
4110d6ec15 Do some basic cleaning on dynamic subjects 2022-11-07 15:58:18 +01:00
Raphael Michel
9bea383ff0 Make all email subjects configurable (#2884)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-11-07 15:50:09 +01:00
Raphael Michel
2287c8b34c Bump django-filter to 22.1 2022-11-07 15:35:31 +01:00
Raphael Michel
f7a129854e Bump pytest-mock to 3.10 2022-11-07 15:35:31 +01:00
Raphael Michel
a96fccef63 Bump pyjwt to 2.6.* 2022-11-07 15:35:31 +01:00
Raphael Michel
dc5a85b39e PDF: Fix another crash if unknown font is used
see also PRETIXEU-7K4
2022-11-07 15:35:31 +01:00
dependabot[bot]
23f9fb4a9a Bump @rollup/plugin-babel in /src/pretix/static/npm_dir
Bumps [@rollup/plugin-babel](https://github.com/rollup/plugins/tree/HEAD/packages/babel) from 5.3.1 to 6.0.2.
- [Release notes](https://github.com/rollup/plugins/releases)
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/babel/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/babel-v6.0.2/packages/babel)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-babel"
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-07 15:33:54 +01:00
dependabot[bot]
6130c45b3e Bump vue and vue-template-compiler in /src/pretix/static/npm_dir
Bumps [vue](https://github.com/vuejs/core) and [vue-template-compiler](https://github.com/vuejs/vue). These dependencies needed to be updated together.

Updates `vue` from 2.7.10 to 2.7.13
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/commits)

Updates `vue-template-compiler` from 2.7.10 to 2.7.13
- [Release notes](https://github.com/vuejs/vue/releases)
- [Changelog](https://github.com/vuejs/vue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/vue/compare/v2.7.10...v2.7.13)

---
updated-dependencies:
- dependency-name: vue
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: vue-template-compiler
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-07 15:33:44 +01:00
dependabot[bot]
83840c4024 Bump @babel/core from 7.19.3 to 7.19.6 in /src/pretix/static/npm_dir (#2880)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-07 15:33:33 +01:00
Maciej Szymczak
02d1d1e0c3 Translations: Update Polish
Currently translated at 14.1% (688 of 4878 strings)

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

powered by weblate
2022-11-07 09:08:18 +01:00
Maciej Szymczak
f641f0fdd1 Translations: Update Polish
Currently translated at 13.8% (677 of 4878 strings)

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

powered by weblate
2022-11-07 09:08:18 +01:00
Raphael Michel
0c827c94a8 Fix mail text preview for languages down below in the list 2022-11-04 13:18:07 +01:00
Raphael Michel
4fb76f1b55 API: Fix overriding date_admission during event clone 2022-11-04 10:39:21 +01:00
Raphael Michel
cb3b1f3ac5 API: Add discount to order position serializer 2022-11-03 15:29:20 +01:00
Richard Schreiber
0b95f89882 Fix paypal disabling continue button (Z#23110784) (#2875) 2022-11-03 13:27:30 +01:00
Raphael Michel
bccd7cd1a4 API: Fix setting plugins during event creation 2022-11-01 18:39:01 +01:00
Richard Schreiber
9c33078a40 Fix isort error 2022-11-01 17:15:27 +01:00
Raphael Michel
6403e5370a Don't crash if a exporter signal returns None 2022-11-01 13:40:22 +01:00
Raphael Michel
3fe2a0455f Fix crash in CartManager 2022-11-01 12:14:30 +01:00
pretix translation bot
6956b198ae Update translations (#2874)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-11-01 11:27:26 +01:00
Raphael Michel
36f7a3d3a3 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-10-31 16:56:46 +01:00
Fazenda Dengo
587e1a1c96 Translations: Update Portuguese (Portugal)
Currently translated at 76.6% (3737 of 4877 strings)

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

powered by weblate
2022-10-31 16:56:14 +01:00
Raphael Michel
8707ab5277 Use a more human-friendly file name for calendar attachments 2022-10-31 16:55:49 +01:00
Raphael Michel
4f6fa84fa7 Fix locking timeout no longer working after redis version change 2022-10-31 16:02:42 +01:00
Raphael Michel
e76d13bf8e Improve logging of periodic command errors 2022-10-31 15:23:32 +01:00
Raphael Michel
39449ecbbe Sentry: Set propagate_traces=False 2022-10-31 14:13:59 +01:00
Raphael Michel
0204b42587 Revert "Attempt downgrade to sentry-sdk 1.8.*"
This reverts commit c1d1e437cc.
2022-10-31 14:10:11 +01:00
Raphael Michel
c1d1e437cc Attempt downgrade to sentry-sdk 1.8.* 2022-10-31 12:30:03 +01:00
Raphael Michel
2fe0ceb4c7 PDF: Fail gracefully on unknown font 2022-10-31 09:53:06 +01:00
Raphael Michel
4cba292b57 Bump to 4.15.0.dev0 2022-10-28 13:34:05 +02:00
Raphael Michel
9e91197c5d Bump to 4.14.0 2022-10-28 13:32:30 +02:00
Raphael Michel
10a8cf3758 Split OverviewReport into modular functions 2022-10-27 22:41:05 +02:00
Raphael Michel
d1deb35711 Add support for base_qs parameter in order_overview function 2022-10-27 22:41:05 +02:00
Raphael Michel
c4d2b0bff7 Fix handling of default ticket layouts during event cloning 2022-10-27 21:55:08 +02:00
Raphael Michel
2d8ceb3255 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4877 of 4877 strings)

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

powered by weblate
2022-10-26 21:13:19 +02:00
Raphael Michel
176e5f115b Translations: Update German
Currently translated at 100.0% (4877 of 4877 strings)

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

powered by weblate
2022-10-26 21:13:19 +02:00
Raphael Michel
9939793e91 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2022-10-26 20:57:05 +02:00
Raphael Michel
7d3cd16785 Add workaround for https://github.com/getsentry/sentry-python/issues/1700 2022-10-26 11:58:44 +02:00
Raphael Michel
7c5fac306a Bank transfer: Match orders based on invoice number (#2867) 2022-10-26 11:06:45 +02:00
Raphael Michel
37683781d0 Fix incorrect variable use in test 2022-10-26 10:49:48 +02:00
Raphael Michel
89dda69205 Allow to sort export of all tickets or badges by question answer (#2865) 2022-10-26 10:43:13 +02:00
Raphael Michel
f2c72e5ff8 Bump pytest to 7.2.* 2022-10-26 09:39:40 +02:00
Raphael Michel
780ebfe120 Bump sentry-sdk to 1.10.* 2022-10-26 09:39:40 +02:00
Raphael Michel
c7d5b687f3 Bump django-countries to 7.4.* 2022-10-26 09:39:40 +02:00
Raphael Michel
5fcb51f372 Bump sepaxml to 2.5.* 2022-10-26 09:39:40 +02:00
FlorianKohlerb88f86e20d524626
9b08f1b286 Translations: Update French
Currently translated at 47.0% (2291 of 4870 strings)

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

powered by weblate
2022-10-26 09:21:54 +02:00
Raphael Michel
4f35be7a25 Fix isort issue 2022-10-25 22:35:16 +02:00
Raphael Michel
884dbff4b8 Log details of API exceptions 2022-10-25 17:57:25 +02:00
Raphael Michel
51768eaef9 Add support for request ID headers 2022-10-25 17:17:59 +02:00
Raphael Michel
45f579caf2 Vouchers: Fix label on redemption page 2022-10-25 15:00:00 +02:00
fyksen
a29dbd88ac Translations: Update Norwegian Bokmål
Currently translated at 7.0% (344 of 4870 strings)

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

powered by weblate
2022-10-25 14:55:12 +02:00
fyksen
957337b091 Translations: Update Norwegian Bokmål
Currently translated at 7.0% (343 of 4870 strings)

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

powered by weblate
2022-10-25 14:55:12 +02:00
Raphael Michel
4983073172 API: Fix crash with deletion of cart positions with add-ons 2022-10-25 12:08:58 +02:00
Raphael Michel
b99d21df69 Fix crash in event creation with very long event names 2022-10-25 12:04:52 +02:00
Raphael Michel
2cfffe6526 Fix edge case in item add-on formset validation 2022-10-25 11:55:21 +02:00
Raphael Michel
87a413ea42 API: Enforce that Item.default_price can't be null 2022-10-25 11:39:48 +02:00
Raphael Michel
4146437380 Do not ask people to enter an address if they can't 2022-10-25 09:27:02 +02:00
Richard Schreiber
b4a7369642 Fix: make hidden form inputs visible, if invalid (Z#23110236) 2022-10-21 11:11:45 +02:00
Raphael Michel
f9b51a8abb Fix incorrect handling of native customer logins 2022-10-20 18:07:46 +02:00
Raphael Michel
d69d70cfb1 Voucher: Add min_usages parameter (#2853) 2022-10-20 18:07:24 +02:00
Martin Gross
ba2d908a89 Security Profiles: Add stripeterminal.paymentintent to POS (#2850) 2022-10-19 17:57:44 +02:00
Raphael Michel
c05abcbccd Bump stripe to 4.2.* and raise Stripe API version 2022-10-19 17:55:55 +02:00
Martin Gross
e16fd61bec Stripe Connect: Fix account name retrieval (#2857) 2022-10-19 17:55:34 +02:00
Raphael Michel
a29d69d8f7 Fix subevent calender closed after month switch 2022-10-19 17:35:40 +02:00
Raphael Michel
e063ad7dda Set payment_banktransfer_invoice_immediately by default 2022-10-19 17:28:22 +02:00
Raphael Michel
7c2bacf3b5 Fix crash on rendering error 404 page 2022-10-19 16:52:35 +02:00
Raphael Michel
c921ca4e65 API: Fix crash when sorting orderpositions by attendee name 2022-10-19 15:32:29 +02:00
Raphael Michel
29a36057ed Fix border of navbar (that appeared out of nowhere?) 2022-10-19 10:52:47 +02:00
robbi5
5eeecf9214 Set default ticket layout QR code content explicitly to secret (#2858) 2022-10-18 17:24:28 +02:00
Raphael Michel
5992abcb7d Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4870 of 4870 strings)

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

powered by weblate
2022-10-12 10:30:54 +02:00
Fazenda Dengo
0db7ec3169 Translations: Update Portuguese (Portugal)
Currently translated at 76.7% (3737 of 4870 strings)

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

powered by weblate
2022-10-12 09:06:41 +02:00
Raphael Michel
8046bf98b7 Make link more visible on redirect.html page 2022-10-11 18:04:38 +02:00
Raphael Michel
9ed39ab0fa Stripe: Prevent lost session with firefox tracking protection 2022-10-11 18:04:30 +02:00
Raphael Michel
7e79fc8b5e Add title scheme "dr_prof_he" for person names 2022-10-11 14:57:19 +02:00
Raphael Michel
9da68645da Replace phrase "presale period" with "booking period" 2022-10-11 11:34:23 +02:00
Fazenda Dengo
f7a4b66da1 Translations: Update Portuguese (Portugal)
Currently translated at 75.9% (3697 of 4869 strings)

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

powered by weblate
2022-10-10 16:20:22 +02:00
Fazenda Dengo
c9212a483b Translations: Update Portuguese (Portugal)
Currently translated at 75.4% (3675 of 4869 strings)

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

powered by weblate
2022-10-10 16:20:22 +02:00
Raphael Michel
cc4e946d95 API: Fix order creation with nested cart positions 2022-10-10 13:45:11 +02:00
Raphael Michel
9d1cfd1eb6 Clarify cart order (#2844) 2022-10-10 12:59:49 +02:00
Raphael Michel
38969747f4 API: New implementation for cart creation (#2833) 2022-10-10 12:59:38 +02:00
Raphael Michel
6e7af4c64b API: Add device info to all security profiles 2022-10-10 12:36:27 +02:00
Raphael Michel
fb45f9f08c Fix readthedocs build 2022-10-10 11:47:00 +02:00
Raphael Michel
6848ce24eb Attempt to fix readthedocs build config 2022-10-10 09:53:36 +02:00
Raphael Michel
dac4fd8d3c Attempt to fix readthedocs build config 2022-10-10 09:49:49 +02:00
Raphael Michel
6905d3e801 Attempt to fix readthedocs build config 2022-10-10 09:41:29 +02:00
Raphael Michel
909b16be64 Attempt to fix readthedocs build config 2022-10-10 09:33:42 +02:00
Raphael Michel
a18162cc47 Attempt to fix readthedocs build config 2022-10-10 09:21:43 +02:00
Raphael Michel
6f0fc9ed49 Fix form validation of cancellation form 2022-10-07 12:39:39 +02:00
Raphael Michel
2409c513d6 Remove useless margins at the end of panel boxes 2022-10-07 10:17:29 +02:00
Raphael Michel
0a95f90012 OIDC RP: Use a separator value in state that is less likely to get lost in transit 2022-10-07 09:42:50 +02:00
Julian Rother
edbd24e942 Checkout: do not show bundled products as "Selected add-ons" in questions step (#2820) 2022-10-07 09:12:13 +02:00
Martin Gross
3940af868b Mail: Fix retry on non-permanent failures (PRETIXEU-7E3) 2022-10-06 18:17:12 +02:00
Raphael Michel
8b4197d868 Bump djangorestframework to 3.14.* 2022-10-06 16:05:35 +02:00
Raphael Michel
632e441c24 Bump django-statici18n to 2.3.* 2022-10-06 14:31:04 +02:00
Andreas Grillenberger
c73ede81ae Docs: Fix incorrect endpoint URL (#2829) 2022-10-06 14:25:08 +02:00
Raphael Michel
c4b7aeaaa2 Consistently set default background PDFs on server, not client (#2840)
Co-authored-by: Martin Gross <gross@rami.io>
2022-10-06 14:14:56 +02:00
Raphael Michel
b5bd98336a Docs: Update API docs for digital content plugin 2022-10-06 10:57:00 +02:00
Raphael Michel
5af52f6087 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4869 of 4869 strings)

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

powered by weblate
2022-10-06 10:51:04 +02:00
Raphael Michel
c5e4d06921 Translations: Update German
Currently translated at 100.0% (4869 of 4869 strings)

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

powered by weblate
2022-10-06 10:51:04 +02:00
Raphael Michel
917cc00091 Translations: Update German
Currently translated at 100.0% (4869 of 4869 strings)

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

powered by weblate
2022-10-06 10:51:04 +02:00
Raphael Michel
63cb88bfb8 Fix crash in OrderChangeManager log entry generation 2022-10-06 09:41:38 +02:00
Raphael Michel
ac1fe15b6c Fix order-level exports through the API 2022-10-05 17:38:28 +02:00
Raphael Michel
ddaa0570bc Revert "Use a temporary file for exports for more stable writing" 2022-10-05 13:39:20 +02:00
Raphael Michel
07352743f2 Fix missing seek call in export task 2022-10-05 13:31:53 +02:00
Raphael Michel
f99ef5fff2 Fix regression when exporting empty data 2022-10-05 13:22:46 +02:00
Raphael Michel
9d686072e2 Fix regression in export task 2022-10-05 12:56:28 +02:00
Raphael Michel
4e44a2809b Fix safe_openpyxl implementation to not leak memory in WriteOnlyWorksheet 2022-10-05 12:27:29 +02:00
Raphael Michel
370e4eafc2 Use a temporary file for exports for more stable writing 2022-10-05 12:26:36 +02:00
Raphael Michel
b7ec372ebc Add exporter for list of customers 2022-10-05 10:36:57 +02:00
Raphael Michel
60cdfe4029 Allow organizer-level exports with separate permission and no event selection 2022-10-05 10:36:57 +02:00
Raphael Michel
74e14285ee Remove hack for gift card exporters, it's not required 2022-10-05 10:36:57 +02:00
Richard Schreiber
8f56ab54a4 PDF/Badges: Improve performance/reduce filesize when creating multiple badges (#2824)
* improve bg performance by using pdftk

* fix handling of rotated background-PDFs
2022-10-05 06:12:23 +02:00
Raphael Michel
4ac58654a0 Run isort on generated protobuf code 2022-10-04 18:03:49 +02:00
Raphael Michel
167eb06aeb Bump django-debug-toolbar to 3.7 2022-10-04 18:03:49 +02:00
Raphael Michel
9a0cc7e8c1 Bump pytest-mock to 3.9 2022-10-04 18:03:49 +02:00
dependabot[bot]
d4ff1808d5 Bump @babel/preset-env in /src/pretix/static/npm_dir
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.19.1 to 7.19.3.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.19.3/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-04 18:02:05 +02:00
Raphael Michel
0ff22786cb Fix typeahead for item meta values with limited access 2022-10-04 16:58:11 +02:00
yvovandoorn
abfb53872c bump css-inline from 0.7.x to 0.8.x to allow for successful arm64 installs 2022-10-04 16:58:09 +02:00
dependabot[bot]
67f60a9e09 Bump rollup from 2.79.0 to 2.79.1 in /src/pretix/static/npm_dir
Bumps [rollup](https://github.com/rollup/rollup) from 2.79.0 to 2.79.1.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.79.0...v2.79.1)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-04 16:45:12 +02:00
Raphael Michel
1d04d40507 Bump protobuf to 4.21.*, regenerate protobuf file 2022-10-04 16:43:34 +02:00
Raphael Michel
14fdd7cfca Bump django-compressor to 4.1.* 2022-10-04 16:43:34 +02:00
Raphael Michel
402ed61756 Bump PyPDF2 to 2.11.* 2022-10-04 16:43:34 +02:00
dependabot[bot]
66c75cbb1b Bump @babel/core from 7.19.1 to 7.19.3 in /src/pretix/static/npm_dir
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.19.1 to 7.19.3.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.19.3/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-04 16:28:39 +02:00
dependabot[bot]
c32791c7dd Bump @rollup/plugin-node-resolve in /src/pretix/static/npm_dir
Bumps [@rollup/plugin-node-resolve](https://github.com/rollup/plugins/tree/HEAD/packages/node-resolve) from 13.3.0 to 14.1.0.
- [Release notes](https://github.com/rollup/plugins/releases)
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/node-resolve/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/node-resolve-v14.1.0/packages/node-resolve)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-node-resolve"
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-04 16:28:32 +02:00
Richard Schreiber
d6846d8415 Cart: change icon from checkbox to arrow-right for voucher submit (#2832) 2022-10-04 11:06:39 +02:00
Raphael Michel
b1c8efa33f AsyncFormView/AsyncPostView: Allow to report status back 2022-09-30 13:58:07 +02:00
Raphael Michel
f14d031de4 Fix semantics of LockTimeoutException and LockReleaseException 2022-09-30 13:41:51 +02:00
Raphael Michel
25c86db6f5 Do not try to unserialize empty string as phone number 2022-09-30 13:28:02 +02:00
Richard Schreiber
7205d0689e Badges: fix pagesizes for 8 A7 on A4-page 2022-09-30 09:18:54 +02:00
Raphael Michel
cde46012cb Add .badge-variant styles 2022-09-29 18:05:21 +02:00
Richard Schreiber
e4a0122938 fix pagesizes and offsets 2022-09-29 17:30:38 +02:00
Raphael Michel
77c08cb710 Fix whitespace issue in EPC QR generation 2022-09-29 13:55:50 +02:00
Raphael Michel
af49a02047 Bump version to 4.14.0.dev0a 2022-09-29 13:37:55 +02:00
Raphael Michel
11495c80e3 Bump version to 4.13.0 2022-09-29 13:37:13 +02:00
Raphael Michel
00ab996640 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4869 of 4869 strings)

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

powered by weblate
2022-09-29 12:24:42 +02:00
Raphael Michel
a4f77b3e4a Translations: Update German
Currently translated at 100.0% (4869 of 4869 strings)

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

powered by weblate
2022-09-29 12:24:42 +02:00
Raphael Michel
1839dcdb74 Fix off-by-one error in retry intervals 2022-09-29 11:39:24 +02:00
Raphael Michel
6bba37288e Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-09-29 11:24:28 +02:00
Mauro Amico
0c3a12b4d3 Translations: Update Italian
Currently translated at 19.0% (927 of 4867 strings)

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

powered by weblate
2022-09-29 11:23:59 +02:00
Anna-itk
7e0b590e10 Translations: Update Danish
Currently translated at 35.4% (1723 of 4867 strings)

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

powered by weblate
2022-09-29 11:23:59 +02:00
Raphael Michel
009f100375 Cart: Display included taxes in total sum from the start 2022-09-29 10:25:10 +02:00
Raphael Michel
4fdbe3912a Workaround for translation of some country names
Workaround for https://github.com/SmileyChris/django-countries/issues/364
2022-09-29 10:25:10 +02:00
Raphael Michel
d4af9130e0 Fix styling of headlines in alert boxes 2022-09-28 23:03:55 +02:00
Raphael Michel
d56e2de409 Bank transfer: Do not show EPC-QR and GiroCode for non-EUR payments 2022-09-28 13:38:18 +02:00
Martin Gross
6a22cb3021 PayPal2: Refuse ISU-connection with unconfirmed email address 2022-09-28 13:20:20 +02:00
Richard Schreiber
814e8fc73b Fix hidden bulk-action filter-form (Z#23108559) (#2817) 2022-09-27 10:25:37 +02:00
Bernd Mohring
6aedfbd42e Docs: Correct Parameter Name/Description for mail() (#2814) 2022-09-22 21:56:19 +02:00
Richard Schreiber
4207b2c0fb Improve voucher redeem if no products are available (#2813) 2022-09-22 21:55:24 +02:00
Richard Schreiber
f35eb2a2f4 Fix ticket download-date hint when plugins disallow download (#2812) 2022-09-22 13:46:35 +02:00
Martin Gross
4b49782fac Invoices: Explicitly set regular_font for main invoice table 2022-09-22 10:42:10 +02:00
Raphael Michel
8fb38d8838 API: Add new /device/info endpoint 2022-09-21 10:22:41 +02:00
Raphael Michel
925077e30f Fix crash on password change page when not logged in 2022-09-21 10:05:01 +02:00
Richard Schreiber
9e07a40ae9 PDF editor: Fix empty preview tab in Chrome (#2806) 2022-09-20 11:14:28 +02:00
Raphael Michel
c7c3aa2c95 Bump django-bootstrap3 to 22.1.* 2022-09-20 11:00:27 +02:00
Raphael Michel
b55c70817a Bump Pillow to 9.2.* 2022-09-20 10:58:57 +02:00
Raphael Michel
18934810f1 Bump BeautifulSoup4 to 4.11 2022-09-20 10:58:54 +02:00
Raphael Michel
865e4c14a2 Bump requests to 2.28.* 2022-09-20 10:55:36 +02:00
Raphael Michel
8cb9f2d742 Bump django-phonenumber-field to 7.0.* 2022-09-20 10:48:15 +02:00
Raphael Michel
d0d3e5ffe4 Bump chardet to 5.0.* 2022-09-20 10:48:10 +02:00
Raphael Michel
857f56c286 Bump flake8 to 5.0 2022-09-20 10:37:57 +02:00
Raphael Michel
167eb85aa3 Bump redis-py to 4.3.* 2022-09-20 10:33:57 +02:00
Raphael Michel
e2f983542e Bump django-countries to 7.3 2022-09-20 10:25:36 +02:00
Raphael Michel
cf622392c0 Ensure uniqueness of question identifiers (#2358) 2022-09-20 10:20:56 +02:00
Raphael Michel
913a83b43d Fix customer being created with name "None" via SSO 2022-09-19 22:04:55 +02:00
Raphael Michel
b79a3a9c2f Fix domain update in organizer 2022-09-19 22:00:53 +02:00
Raphael Michel
b3a1ee3127 Translations: Update German
Currently translated at 100.0% (4867 of 4867 strings)

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

powered by weblate
2022-09-19 21:34:49 +02:00
Raphael Michel
18db00f310 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4867 of 4867 strings)

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

powered by weblate
2022-09-19 21:34:49 +02:00
Raphael Michel
9e4d6a9e97 Add new words to spellcheck wordlist 2022-09-19 21:32:24 +02:00
Raphael Michel
21148c6772 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-09-19 18:32:10 +02:00
Mauro Amico
70a3defc2c Translations: Update Italian
Currently translated at 19.2% (923 of 4791 strings)

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

powered by weblate
2022-09-19 18:31:38 +02:00
Michele Pagnozzi
535399b2e1 Translations: Update Italian
Currently translated at 19.2% (923 of 4791 strings)

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

powered by weblate
2022-09-19 18:31:38 +02:00
Raphael Michel
278111b15b Add documentation on customer accounts and SSO 2022-09-19 18:31:07 +02:00
Raphael Michel
a4171ef819 OpenID Connect OP support for customer accounts 2022-09-19 18:31:07 +02:00
Raphael Michel
7f5518dbf6 OpenID Connect RP support for customer accounts 2022-09-19 18:31:07 +02:00
Mauro Amico
e102a590ab Translations: Update Italian
Currently translated at 18.0% (865 of 4791 strings)

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

powered by weblate
2022-09-19 15:56:40 +02:00
Svyatoslav
383dc5ab9a Translations: Update Latvian
Currently translated at 44.8% (2148 of 4791 strings)

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

powered by weblate
2022-09-19 15:56:40 +02:00
Svyatoslav
4dc65f3858 Translations: Update Russian
Currently translated at 23.7% (1139 of 4791 strings)

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

powered by weblate
2022-09-19 15:56:40 +02:00
Ismael Menéndez Fernández
e4dccf87d4 Translations: Update Galician
Currently translated at 12.2% (587 of 4791 strings)

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

powered by weblate
2022-09-19 15:56:40 +02:00
Raphael Michel
7dece3732c Fix transaction warning in import with auto-checkin 2022-09-19 09:46:26 +02:00
Raphael Michel
015c22662f Fix incorrect syntax of issue template 2022-09-16 13:45:43 +02:00
Raphael Michel
36b3968667 Add SECURITY.md and new issue templates 2022-09-16 13:43:51 +02:00
Richard Schreiber
4b9932420b PDF: Fix missing backgrounds when outputting multiple tickets/badges per page (Z#23107984) 2022-09-16 11:06:24 +02:00
Raphael Michel
87cfd8f538 Fix name placeholder not being used correctly in scheduled emails (#2794) 2022-09-15 13:35:35 +02:00
Raphael Michel
0fc7d78281 Mail sending: Prevent retry intervals higher than visibility_timeout (1h) 2022-09-15 11:26:25 +02:00
Raphael Michel
39086e81ac Fix missing countdown in new webhook retry logic 2022-09-15 11:23:10 +02:00
Raphael Michel
c1233ed692 New retry logic for webhooks (#2790)
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2022-09-15 09:41:39 +02:00
Richard Schreiber
1a401ec1e9 Fix: display cleaned filename in upload forms (#2788) 2022-09-15 09:41:32 +02:00
Mauro Amico
79beeebfa6 Upgrade package-lock to v2 (#2769)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-09-15 09:40:44 +02:00
Raphael Michel
ccf5bea367 Revert "Bump pep8-naming and pycodestyle"
This reverts commit c77790821d.
2022-09-14 19:10:30 +02:00
Raphael Michel
c77790821d Bump pep8-naming and pycodestyle 2022-09-14 18:53:43 +02:00
Christopher Banck
95ea2849c2 PDF: Add event weekday variable (#2789) 2022-09-14 18:51:37 +02:00
Raphael Michel
e45c162b3d Bank transfer: Minimal implementation of Swiss QR-bill (#2767)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-09-14 17:53:23 +02:00
Raphael Michel
9cdb1a9258 Fix documentation spellcheck issue 2022-09-14 12:42:19 +02:00
Martin Gross
241169873b Stripe: Automatically fail payments if the payment_intent has failed. 2022-09-14 10:07:34 +02:00
Andreas Schlapbach
690edf1c68 Correct backticks for analytics.js (#2791) 2022-09-13 21:39:22 +02:00
Christopher Banck
f606747dc9 Retain filenames when cloning an event 2022-09-13 10:46:08 +02:00
Raphael Michel
f2593d9b4b Fix crash if product with bundles has no tax rule 2022-09-12 21:20:22 +02:00
dependabot[bot]
cddd720a15 Bump rollup from 2.77.2 to 2.79.0 in /src/pretix/static/npm_dir (#2774)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 16:36:10 +02:00
dependabot[bot]
3c25dfe861 Bump @babel/preset-env from 7.18.9 to 7.19.0 in /src/pretix/static/npm_dir (#2787)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 16:36:00 +02:00
dependabot[bot]
97a7fc89e0 Bump vue and vue-template-compiler in /src/pretix/static/npm_dir (#2777)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 13:30:36 +02:00
dependabot[bot]
1742bb440c Bump @babel/core from 7.18.10 to 7.18.13 in /src/pretix/static/npm_dir (#2775)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-12 13:30:29 +02:00
Thomas Vranken
73717eaacf Translations: Update Dutch
Currently translated at 92.9% (4455 of 4791 strings)

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

powered by weblate
2022-09-12 13:29:47 +02:00
Ismael Menéndez Fernández
b84aa7f42e Translations: Update Galician
Currently translated at 11.7% (564 of 4791 strings)

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

powered by weblate
2022-09-12 13:29:47 +02:00
Mika Lammi
ef0f2dae77 Translations: Update Finnish
Currently translated at 18.2% (876 of 4791 strings)

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

powered by weblate
2022-09-12 13:29:47 +02:00
Raphael Michel
dcec0b03d4 Fix handling of contradictory input in image upload (PRETIXEU-78F) 2022-09-12 13:18:02 +02:00
Raphael Michel
db93981bac Don't try to cancel payment if provider plugin is disabled (PRETIXEU-754) 2022-09-12 13:06:03 +02:00
Raphael Michel
865bd126f3 Fix partial PATCH for gift cards (PRETIXEU-75P) 2022-09-12 12:59:39 +02:00
Raphael Michel
948952875c PayPal: Add additional error handling for old SDK (PRETIXEU-77B) 2022-09-12 12:57:58 +02:00
Raphael Michel
489ad87ad6 Fix rare crash in cart ID creation (PRETIXEU-78E) 2022-09-12 12:57:58 +02:00
Raphael Michel
62f7bd4fa5 Iterate cdb564970 to a fix that actually tackles the root cause 2022-09-12 12:57:58 +02:00
Martin Gross
353c9b4147 VAT: Transmit the country code of the normalized VAT-ID instead of the user provided one 2022-09-12 12:48:58 +02:00
Richard Schreiber
50a557b247 Widget: prevent clicks to bubble up outside the widget 2022-09-09 09:40:13 +02:00
Richard Schreiber
1dd3ed6057 Fix discount for distinct dates when only certain products are allowed (Z#23107419) (#2783) 2022-09-08 12:24:18 +02:00
Martin Gross
b1c6508a5c Update po files
[CI skip]

Signed-off-by: Martin Gross <gross@rami.io>
2022-09-08 12:09:42 +02:00
Martin Gross
7bc2e3ebb4 Webhooks: Spellcheck 2022-09-08 12:07:55 +02:00
Martin Gross
cd6b21120c Control/Order Details: Open invoices/cancellations PDFs in new tab 2022-09-08 12:04:22 +02:00
pretix translation bot
51d37f7474 Update translations (#2781)
Co-authored-by: Mika Lammi <mika.lammi@gmail.com>
Co-authored-by: Ismael Menéndez Fernández <ismael.menendez@balidea.com>
Co-authored-by: Martin Gross <gross@rami.io>
2022-09-08 12:02:19 +02:00
Richard Schreiber
61d5e66ad4 Fix JS-error on custom select2 focus handler 2022-09-08 10:20:13 +02:00
Martin Gross
603547f783 Update .po-files 2022-09-07 12:40:38 +02:00
Weblate
bf43be9312 Merge remote-tracking branch 'refs/remotes/origin/master' 2022-09-06 14:14:03 +02:00
Martin Gross
06a2dbd281 Update po files
[CI skip]

Signed-off-by: Martin Gross <gross@rami.io>
2022-09-06 14:12:02 +02:00
pretix translation bot
0cedb8d4db Update translations (#2780)
Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Weblate <noreply@weblate.org>
2022-09-06 14:10:31 +02:00
Weblate
faa6c97536 Merge branch 'master' of https://github.com/pretix/pretix 2022-09-06 14:08:23 +02:00
Martin Gross
b65424ee3d Webhooks: Add Shop live/offline and Testmode on/off 2022-09-02 19:13:06 +02:00
Martin Gross
cf060f353d VAT Check: Surface validation errors during normalization 2022-08-29 13:14:33 +02:00
Martin Gross
cdb5649709 API: Explicitly call order.create_transactions() on Order Position PATCH/updates (PRETIXEU-766) 2022-08-26 17:06:08 +02:00
Martin Gross
8226e6c6d5 Webhooks: Add order.expirychanged and all order.refund.* Events 2022-08-25 10:25:01 +02:00
Martin Gross
0d453f3454 EU VAT Check: Query VAT RegEx list based on sanatized vat_prefix (GR vs. EL) PRETIXEU-75A 2022-08-23 11:43:39 +02:00
Martin Gross
80e0978054 PPv2: Check for OID and not PAYID in degraded failback-mode 2022-08-19 15:13:58 +02:00
Michael Stapelberg
5a8c567d02 API: allow setting password when creating customer (#2758)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-08-16 14:40:24 +02:00
Raphael Michel
9199d24df2 Add missing license header 2022-08-16 14:05:39 +02:00
Raphael Michel
41a05adb44 Update pytest requirement to 7.*, pytest-mock to 3.8, pytest-rerunfailures to 10.2, pytest-xdist to 2.5 2022-08-16 14:03:34 +02:00
Raphael Michel
95baca6920 Update oauthlib requirement from ==3.1.* to ==3.2.* 2022-08-16 14:00:51 +02:00
Raphael Michel
cff882edc0 aFix isort issues 2022-08-16 13:58:42 +02:00
Raphael Michel
b9feceba49 Do not show a price if there are mandatory non-free addons 2022-08-15 14:54:51 +02:00
Raphael Michel
2d584d115d Reorganize main.js in control module 2022-08-15 14:19:02 +02:00
Raphael Michel
b02fb7ffa8 FIx a few inconsistencies in AsyncFormView 2022-08-15 14:18:52 +02:00
Raphael Michel
e66506fa5b Order import: Do not lock if not necessary 2022-08-15 14:18:29 +02:00
Raphael Michel
65b4741e27 Fix crash on VAT IDs with missing country prefix 2022-08-14 21:44:50 +02:00
Raphael Michel
b5e5796549 Move VAT ID validation logic from vat_moss to core, support Norway 2022-08-14 13:50:18 +02:00
Raphael Michel
b6945687a6 Add last order modification time as an email placeholder (#2761)
* Add last order modification time as an email placeholder

* Update src/pretix/base/email.py

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2022-08-11 21:41:13 +02:00
Raphael Michel
b586a52813 Improve settings link for propagated settings 2022-08-11 16:14:37 +02:00
Raphael Michel
000407bcaf PPv2: Fix payment button on late payment page 2022-08-10 14:30:24 +02:00
Richard Schreiber
47989fd139 Fix: add alt attribute to default html email images 2022-08-10 09:16:41 +02:00
Raphael Michel
436ed26185 Translations: Update German
Currently translated at 100.0% (4772 of 4772 strings)

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

powered by weblate
2022-07-26 11:01:04 +02:00
Raphael Michel
696daae641 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (201 of 201 strings)

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

powered by weblate
2022-07-26 10:58:23 +02:00
Raphael Michel
5e860c30f3 Translations: Update German
Currently translated at 100.0% (201 of 201 strings)

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

powered by weblate
2022-07-26 10:58:22 +02:00
Raphael Michel
e5b7afe85a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4772 of 4772 strings)

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

powered by weblate
2022-07-26 10:58:22 +02:00
Raphael Michel
53a0d63dce Translations: Update German
Currently translated at 100.0% (4772 of 4772 strings)

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

powered by weblate
2022-07-26 10:58:22 +02:00
394 changed files with 289044 additions and 197514 deletions

View File

@@ -1,23 +0,0 @@
---
name: Bug report
about: Please only create issues for bug reports. Feature requests or general questions
should start as a "Discussion" on GitHub.
title: ''
labels: ''
assignees: ''
---
<!-- Please only create issues for bug reports. Feature requests or general questions should start as a "Discussion" on GitHub. -->
**Describe the bug**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

53
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Bug report
description: Please only create issues for bug reports. Feature requests or general questions should start as a "Discussion" on GitHub.
body:
- type: markdown
attributes:
value: Please make sure to search our issues for similar bugs first! If bug has been reported already, react with a thumbs-up, and/or leave a comment providing further details.
- type: textarea
id: current
attributes:
label: Problem and impact
description: What problem you're running into? What impact does it have on you / your event?
placeholder: When trying to do ____, pretix suddenly shows me an error saying "...".
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: Sometimes bugs are subtle and the expected behaviour may need some explanation. Leave empty if it's just "Don't be broken."
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: "Please give as much context as possible: Are there any settings that impact this behaviour?"
placeholder: |
1.
2.
3.
4.
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If possible, show screenshots of the problem.
- type: input
id: link
attributes:
label: Link
description: Link to the page where the bug occurs
- type: input
id: browser
attributes:
label: Browser (software, desktop or mobile?) and version
description: Leave empty for backend problems
- type: input
id: os
attributes:
label: Operating system, dependency versions
description: Leave empty for frontend problems
- type: input
id: version
attributes:
label: Version
description: The pretix version in use. (Leave empty if unknown.)

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Community Support
url: https://github.com/pretix/pretix/discussions/categories/q-a
about: Not sure how to do Y? Please post your support requests in the Q&A section of our GitHub Discussions instead!
- name: Feature ideas
url: https://github.com/pretix/pretix/discussions/categories/ideas
about: Please post your idea in the Ideas section of our GitHub Discussions instead!

View File

@@ -14,16 +14,19 @@ on:
- 'src/pretix/static/**'
- 'src/tests/**'
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
spelling:
name: Spellcheck
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -31,7 +34,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system packages
run: sudo apt update && sudo apt install enchant hunspell aspell-en
run: sudo apt update && sudo apt install enchant-2 hunspell aspell-en
- name: Install Dependencies
run: pip3 install -Ur requirements.txt
working-directory: ./doc

View File

@@ -12,16 +12,19 @@ on:
- 'doc/**'
- 'src/pretix/locale/**'
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
compile:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
name: Check gettext syntax
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -40,14 +43,14 @@ jobs:
run: python manage.py compilejsi18n
working-directory: ./src
spelling:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
name: Spellcheck
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -55,7 +58,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system packages
run: sudo apt update && sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
run: sudo apt update && sudo apt install enchant-2 hunspell hunspell-de-de aspell-en aspell-de
- name: Install Dependencies
run: pip3 install -e ".[dev]"
working-directory: ./src

View File

@@ -12,16 +12,19 @@ on:
- 'src/pretix/locale/**'
- 'src/pretix/static/**'
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
isort:
name: isort
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -36,13 +39,13 @@ jobs:
working-directory: ./src
flake:
name: flake8
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -57,13 +60,13 @@ jobs:
working-directory: ./src
licenseheader:
name: licenseheaders
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- name: Install Dependencies
run: pip3 install licenseheaders
- name: Run licenseheaders

View File

@@ -12,23 +12,26 @@ on:
- 'doc/**'
- 'src/pretix/locale/**'
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
name: Tests
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9"]
python-version: ["3.7", "3.9", "3.10"]
database: [sqlite, postgres, mysql]
exclude:
- database: mysql
python-version: "3.8"
python-version: "3.10"
- database: mysql
python-version: "3.9"
- database: sqlite
python-version: "3.7"
- database: sqlite
python-version: "3.8"
python-version: "3.10"
steps:
- uses: actions/checkout@v2
- uses: getong/mariadb-action@v1.1
@@ -55,9 +58,9 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install gettext mariadb-client-10.3
run: sudo apt update && sudo apt install gettext mariadb-client
- name: Install Python dependencies
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
run: pip3 install --ignore-requires-python -e ".[dev]" mysqlclient psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests
working-directory: ./src
- name: Run checks
run: python manage.py check
@@ -76,4 +79,4 @@ jobs:
with:
file: src/coverage.xml
fail_ci_if_error: true
if: matrix.database == 'postgres' && matrix.python-version == '3.8'
if: matrix.database == 'postgres' && matrix.python-version == '3.10'

View File

@@ -1 +0,0 @@
-r doc/requirements.txt

15
.readthedocs.yaml Normal file
View File

@@ -0,0 +1,15 @@
version: 2
sphinx:
configuration: doc/conf.py
build:
os: ubuntu-22.04
tools:
python: "3.8"
nodejs: "16"
apt_packages:
- gettext
python:
install:
- method: pip
path: ./src/
- requirements: doc/requirements.rtd.txt

20
SECURITY.md Normal file
View File

@@ -0,0 +1,20 @@
# Security policy
## Reporting a vulnerability
If you discover a vulnerability with our software or server systems, please report it to us in private. Do not to attempt to harm our users, customer's data or our system's availability when looking for vulneratbilities.
Please contact us at security@pretix.eu with full details and steps to reproduce and allow reasonable time for us to resolve the issue before publishing your findings. If you wish to encrypt your email, you can find our GPG key [here](https://pretix.eu/.well-known/security@pretix.eu.asc).
We're not large enough to run a formal bug bounty program, but if you find a serious vulnerability in our service, we will find a way to show our gratitude.
## Version support
Security support is provided for the current stable release as well as the two previous stable releases.
Be sure to keep your pretix installation up to date.
New releases and security issues will be announced on our [blog](https://pretix.eu/about/en/blog/). If you
subscribe to our [newsletter](https://pretix.eu/about/en/blog/) in the "News about self-hosting pretix"
category, we will also send you an email on security issues.
Past security issues are listed [on our website](https://pretix.eu/about/en/security).

View File

@@ -6067,6 +6067,10 @@ url('../opensans_regular_macroman/OpenSans-Regular-webfont.svg#open_sansregular'
img.screenshot, a.screenshot img {
box-shadow: 0 4px 18px 0 rgba(0,0,0,0.1), 0 6px 20px 0 rgba(0,0,0,0.09);
}
section > a.screenshot {
display: block;
margin-bottom: 24px;
}
/* Changes */
.versionchanged {

View File

@@ -117,6 +117,9 @@ Example::
``loglevel``
Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
``request_id_header``
Specifies the name of a header that should be used for logging request IDs. Off by default.
Locale settings
---------------
@@ -396,9 +399,9 @@ The two ``transport_options`` entries can be omitted in most cases.
If they are present they need to be a valid JSON dictionary.
For possible entries in that dictionary see the `Celery documentation`_.
To use redis with sentinels set the broker or backend to ``sentinel://sentinel_host_1:26379;sentinal_host_2:26379/0``
To use redis with sentinels set the broker or backend to ``sentinel://sentinel_host_1:26379;sentinel_host_2:26379/0``
and the respective transport_options to ``{"master_name":"mymaster"}``.
If your redis instances behind the sentinel have a password use ``sentinel://:my_password@sentinel_host_1:26379;sentinal_host_2:26379/0``.
If your redis instances behind the sentinel have a password use ``sentinel://:my_password@sentinel_host_1:26379;sentinel_host_2:26379/0``.
If your redis sentinels themselves have a password set the transport_options to ``{"master_name":"mymaster","sentinel_kwargs":{"password":"my_password"}}``.
Sentry

View File

@@ -105,6 +105,37 @@ following endpoint:
You will receive a response equivalent to the response of your initialization request.
Device Information
------------------
You can request information about your device and the server with one call:
.. sourcecode:: http
GET /api/v1/device/info HTTP/1.1
Host: pretix.eu
The response will look like this:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"device": {
"organizer": "foo",
"device_id": 5,
"unique_serial": "HHZ9LW9JWP390VFZ",
"api_token": "1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd",
"name": "Bar",
"gate": {
"id": 3,
"name": "South entrance"
}
}
}
Creating a new API key
----------------------

View File

@@ -17,8 +17,8 @@ The cart position resource contains the following public fields:
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.
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
@@ -29,22 +29,23 @@ attendee_name_parts object of strings Composition of
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``).
is_bundled boolean If ``addon_to`` is set, this shows whether this is a bundled product or an addon product
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
seat objects The assigned seat. Can be ``null``.
seat objects The assigned seat (or ``null``)
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
===================================== ========================== =======================================================
.. versionchanged:: 3.0
.. versionchanged:: 4.14
This ``seat`` attribute has been added.
This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.
Cart position endpoints
@@ -87,6 +88,7 @@ Cart position endpoints
"attendee_email": null,
"voucher": null,
"addon_to": null,
"is_bundled": false,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
@@ -133,6 +135,7 @@ Cart position endpoints
"attendee_email": null,
"voucher": null,
"addon_to": null,
"is_bundled": false,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
@@ -168,7 +171,7 @@ Cart position endpoints
* 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 validate constraints on add-on products at the moment
* does not check or calculate prices but believes any prices you send
@@ -176,6 +179,8 @@ Cart position endpoints
* does not support file upload questions
Note that more validation might be added in the future, so please do not rely on missing validation.
You can supply the following fields of the resource:
* ``cart_id`` (optional, needs to end in ``@api``)
@@ -190,6 +195,8 @@ Cart position endpoints
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code)
* ``addons`` (optional, expect a list of nested objects of cart positions)
* ``bundled`` (optional, expect a list of nested objects of cart positions)
* ``answers``
* ``question``
@@ -221,6 +228,12 @@ Cart position endpoints
"options": []
}
],
"addons": [
{
"item": 2,
"variation": null,
}
],
"subevent": null
}
@@ -232,7 +245,7 @@ Cart position endpoints
Vary: Accept
Content-Type: application/json
(Full cart position resource, see above.)
(Full cart position resource, see above, with additional nested objects "addons" and "bundled".)
: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
@@ -244,8 +257,8 @@ Cart position endpoints
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/bulk_create/
Creates multiple new cart position. This operation is deliberately not atomic, so each cart position can succeed
or fail individually, so the response code of the response is not the only thing to look at!
Creates multiple new cart position. **This operation is deliberately not atomic, so each cart position can succeed
or fail individually, so the response code of the response is not the only thing to look at!**
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.

View File

@@ -39,23 +39,6 @@ exit_all_at datetime Automatically c
addon_match boolean If ``true``, tickets on this list can be redeemed by scanning their parent ticket if this still leads to an unambiguous match.
===================================== ========================== =======================================================
.. versionchanged:: 3.9
The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``,
``allow_entry_after_exit``, and ``rules`` attributes have been added.
.. versionchanged:: 3.11
The ``subevent_match`` and ``exclude`` query parameters have been added.
.. versionchanged:: 3.12
The ``exit_all_at`` attribute has been added.
.. versionchanged:: 3.17
The ``ends_after`` and ``expand`` query parameters have been added.
.. versionchanged:: 4.12
The ``addon_match`` attribute has been added.

View File

@@ -14,7 +14,10 @@ The customer resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
identifier string Internal ID of the customer
external_identifier string External ID of the customer (or ``null``)
external_identifier string External ID of the customer (or ``null``). This field can
be changed for customers created manually or through
the API, but is read-only for customers created through a
SSO integration.
email string Customer email address
name string Name of this customer (or ``null``)
name_parts object of strings Decomposition of name (i.e. given name, family name)
@@ -26,10 +29,16 @@ date_joined datetime Date and time o
locale string Preferred language of the customer
last_modified datetime Date and time of modification of the record
notes string Internal notes and comments (or ``null``)
password string Can only be set during creation of a new customer, will
not be included in any responses.
===================================== ========================== =======================================================
.. versionadded:: 4.0
.. versionchanged:: 4.3
Passwords can now be set through the API during customer creation.
Endpoints
---------
@@ -146,6 +155,7 @@ Endpoints
{
"email": "test@example.org",
"password": "verysecret",
"send_email": true
}

View File

@@ -52,34 +52,9 @@ sales_channels list A list of sales
===================================== ========================== =======================================================
.. versionchanged:: 3.3
The attributes ``geo_lat`` and ``geo_lon`` have been added.
.. versionchanged:: 3.4
The attribute ``timezone`` has been added.
.. versionchanged:: 3.7
The attribute ``item_meta_properties`` has been added.
.. versionchanged:: 3.12
The attribute ``valid_keys`` has been added.
.. versionchanged:: 3.14
The attribute ``sales_channels`` has been added.
Endpoints
---------
.. versionchanged:: 3.3
The events resource can now be filtered by meta data attributes.
.. versionchanged:: 4.0
The ``clone_from`` parameter has been added to the event creation endpoint.
@@ -567,10 +542,6 @@ information about the properties.
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
able to break your event using this API by creating situations of conflicting settings. Please take care.
.. versionchanged:: 3.6
Initial support for settings has been added to the API.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/settings/
Get current values of event settings.

View File

@@ -6,10 +6,6 @@ Data exporters
pretix and it's plugins include a number of data exporters that allow you to bulk download various data from pretix in
different formats. This page shows you how to use these exporters through the API.
.. versionchanged:: 3.13
This feature has been added to the API.
.. warning::
While we consider the methods listed on this page to be a stable API, the availability and specific input field

View File

@@ -40,10 +40,6 @@ text string Custom text of
Endpoints
---------
.. versionadded:: 3.14
The transaction list endpoint was added.
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
Returns a list of all gift cards issued by a given organizer.
@@ -257,10 +253,6 @@ Endpoints
"value": "15.37"
}
.. versionchanged:: 3.5
This endpoint now returns status code ``409`` if the transaction would lead to a negative gift card value.
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the gift card to modify
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.

View File

@@ -108,16 +108,6 @@ internal_reference string Customer's refe
===================================== ========================== =======================================================
.. versionchanged:: 3.4
The attribute ``lines.number`` has been added.
.. versionchanged:: 3.17
The attribute ``invoice_to_*``, ``invoice_from_*``, ``custom_field``, ``lines.item``, ``lines.variation``, ``lines.event_date_from``,
``lines.event_date_to``, and ``lines.attendee_name`` have been added.
``refers`` now returns an invoice number including the prefix.
.. versionchanged:: 4.1
The attributes ``fee_type`` and ``fee_internal_type`` have been added.

View File

@@ -43,8 +43,13 @@ available_until datetime The last date t
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== =======================================================
.. versionchanged:: 4.16
The ``meta_data`` attribute has been added.
Endpoints
---------
@@ -94,6 +99,7 @@ Endpoints
"default_price": "223.00",
"price": 223.0,
"original_price": null,
"meta_data": {}
},
{
"id": 3,
@@ -108,7 +114,8 @@ Endpoints
"description": {},
"position": 1,
"default_price": null,
"price": 15.0
"price": 15.0,
"meta_data": {}
}
]
}
@@ -161,7 +168,8 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
"position": 0,
"meta_data": {}
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -198,7 +206,8 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
"position": 0,
"meta_data": {}
}
**Example response**:
@@ -225,7 +234,8 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
"position": 0,
"meta_data": {}
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a variation for
@@ -283,7 +293,8 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1
"position": 1,
"meta_data": {}
}
:param organizer: The ``slug`` field of the organizer to modify

View File

@@ -123,6 +123,7 @@ variations list of objects A list with one
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
├ meta_data object Values set for event-specific meta data parameters.
└ position integer An integer, used for sorting
addons list of objects Definition of add-ons that can be chosen for this item.
Only writable during creation,
@@ -146,14 +147,6 @@ bundles list of objects Definition of b
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== =======================================================
.. versionchanged:: 3.7
The attribute ``meta_data`` has been added.
.. versionchanged:: 3.10
The attribute ``multi_allowed`` has been added to ``addons``.
.. versionchanged:: 4.0
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
@@ -163,6 +156,10 @@ meta_data object Values set for
The attributes ``require_membership_hidden`` attribute has been added.
.. versionchanged:: 4.16
The ``variations[x].meta_data`` attribute has been added.
Notes
-----
@@ -255,6 +252,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -270,6 +268,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -369,6 +368,7 @@ Endpoints
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"meta_data": {},
"position": 0
},
{
@@ -384,6 +384,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -463,6 +464,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -478,6 +480,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -546,6 +549,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -561,6 +565,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -660,6 +665,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -675,6 +681,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],

View File

@@ -98,30 +98,6 @@ last_modified datetime Last modificati
===================================== ========================== =======================================================
.. versionchanged:: 3.5
The ``order.fees.canceled`` attribute has been added.
.. versionchanged:: 3.8
The ``reactivate`` operation has been added.
.. versionchanged:: 3.10
The ``search`` query parameter has been added.
.. versionchanged:: 3.11
The ``exclude`` and ``subevent_after`` query parameter has been added.
.. versionchanged:: 3.13
The ``subevent_before`` query parameter has been added.
.. versionchanged:: 3.14
The ``phone`` attribute has been added.
.. versionchanged:: 4.0
The ``customer`` attribute has been added.
@@ -142,6 +118,10 @@ last_modified datetime Last modificati
The ``order.fees.id`` attribute has been added.
.. versionchanged:: 4.15
The ``include`` query parameter has been added.
.. _order-position-resource:
@@ -178,6 +158,7 @@ tax_rule integer The ID of the u
secret string Secret code printed on the tickets for validation
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``).
discount integer ID of a discount that has been used during the creation of this position in some way (or ``null``).
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
checkins list of objects List of **successful** check-ins with this ticket
├ id integer Internal ID of the check-in event
@@ -205,27 +186,6 @@ pdf_data object Data object req
``pdf_data=true`` query parameter to your request.
===================================== ========================== =======================================================
.. versionchanged:: 3.3
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
:ref:`order-position-ticket-download` for details.
.. versionchanged:: 3.5
The attribute ``canceled`` has been added.
.. versionchanged:: 3.8
The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added.
.. versionchanged:: 3.9
The ``checkin.type`` attribute has been added.
.. versionchanged:: 3.16
Answers to file questions are now returned as an URL.
.. _order-payment-resource:
Order payment resource
@@ -272,15 +232,20 @@ created datetime Date and time o
comment string Reason for refund (shown to the customer in some cases, can be ``null``).
execution_date datetime Date and time of completion of this refund (or ``null``)
provider string Identification string of the payment provider
details object Refund-specific information. This is a dictionary
with various fields that can be different between
payment providers, versions, payment states, etc. If
you read this field, you always need to be able to
deal with situations where values that you expect are
missing. Mostly, the field contains various IDs that
can be used for matching with other systems. If a
payment provider does not implement this feature,
the object is empty.
===================================== ========================== =======================================================
List of all orders
------------------
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/
Returns a list of all orders within a given event.
@@ -371,6 +336,7 @@ List of all orders
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
@@ -447,6 +413,7 @@ List of all orders
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
:query string include: Include only the given field in the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times. ``include`` is applied before ``exclude``, so ``exclude`` takes precedence.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
@@ -458,10 +425,6 @@ List of all orders
Fetching individual orders
--------------------------
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
Returns information on one order, identified by its order code.
@@ -546,6 +509,7 @@ Fetching individual orders
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
@@ -1035,10 +999,6 @@ Creating orders
Order state operations
----------------------
.. versionchanged:: 3.12
The ``mark_paid`` operation now takes a ``send_email`` parameter.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
Marks a pending or expired order as successfully paid.
@@ -1440,10 +1400,6 @@ Sending e-mails
List of all order positions
---------------------------
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Returns a list of all order positions within a given event.
@@ -1487,6 +1443,7 @@ List of all order positions
"tax_rule": null,
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null,
@@ -1597,6 +1554,7 @@ Fetching individual positions
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
@@ -1696,10 +1654,6 @@ Order position ticket download
Manipulating individual positions
---------------------------------
.. versionchanged:: 3.15
The ``PATCH`` method has been added for individual positions.
.. versionchanged:: 4.8
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
@@ -1925,7 +1879,7 @@ otherwise, such as splitting an order or changing fees.
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/change/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
@@ -2006,14 +1960,6 @@ otherwise, such as splitting an order or changing fees.
Order payment endpoints
-----------------------
.. versionchanged:: 3.6
Payments can now be created through the API.
.. versionchanged:: 3.12
The ``confirm`` operation now takes a ``send_email`` parameter.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
Returns a list of all payments for an order.
@@ -2319,6 +2265,7 @@ Order refund endpoints
"created": "2017-12-01T10:00:00Z",
"execution_date": "2017-12-04T12:13:12Z",
"comment": "Cancellation",
"details": {},
"provider": "banktransfer"
}
]
@@ -2362,6 +2309,7 @@ Order refund endpoints
"created": "2017-12-01T10:00:00Z",
"execution_date": "2017-12-04T12:13:12Z",
"comment": "Cancellation",
"details": {},
"provider": "banktransfer"
}
@@ -2419,6 +2367,7 @@ Order refund endpoints
"created": "2017-12-01T10:00:00Z",
"execution_date": null,
"comment": "Cancellation",
"details": {},
"provider": "manual"
}
@@ -2548,10 +2497,6 @@ Revoked ticket secrets
With some non-default ticket secret generation methods, a list of revoked ticket secrets is required for proper validation.
.. versionchanged:: 3.12
Added revocation lists.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/revokedsecrets/
Returns a list of all revoked secrets within a given event.

View File

@@ -109,10 +109,6 @@ information about the properties.
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
able to break your shops using this API by creating situations of conflicting settings. Please take care.
.. versionchanged:: 3.14
Initial support for settings has been added to the API.
.. http:get:: /api/v1/organizers/(organizer)/settings/
Get current values of organizer settings.

View File

@@ -76,26 +76,9 @@ dependency_value string An old version
for one value. **Deprecated.**
===================================== ========================== =======================================================
.. versionchanged:: 3.5
The attribute ``help_text`` has been added.
.. versionchanged:: 3.14
The attributes ``valid_*`` have been added.
.. versionchanged:: 3.18
The attribute ``valid_file_portrait`` have been added.
Endpoints
---------
.. versionchanged:: 1.15
The questions endpoint has been extended by the filter queries ``ask_during_checkin``, ``requred``, and
``identifier``.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/
Returns a list of all questions within a given event.

View File

@@ -36,10 +36,6 @@ available_number integer Number of avail
slightly out of date. ``null`` means unlimited.
===================================== ========================== =======================================================
.. versionchanged:: 3.10
The attribute ``release_after_exit`` has been added.
.. versionchanged:: 4.1
The ``with_availability`` query parameter has been added.

View File

@@ -59,29 +59,13 @@ seat_category_mapping object An object mappi
last_modified datetime Last modification of this object
===================================== ========================== =======================================================
.. versionchanged:: 3.3
.. versionchanged:: 4.15
The attributes ``geo_lat`` and ``geo_lon`` have been added.
.. versionchanged:: 3.10
The ``disabled`` attribute has been added to ``item_price_overrides`` and ``variation_price_overrides``.
.. versionchanged:: 3.12
The ``last_modified`` attribute has been added.
.. versionchanged:: 3.18
The ``available_from``/``available_until`` attributes have been added to ``item_price_overrides`` and ``variation_price_overrides``.
The ``search`` query parameter has been added to filter sub-events by their name or location in any language.
Endpoints
---------
.. versionchanged:: 3.3
The sub-events resource can now be filtered by meta data attributes.
.. versionchanged:: 4.1
The ``with_availability_for`` parameter has been added.
@@ -147,6 +131,7 @@ Endpoints
: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.
:query search: Only return events matching a given search query.
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event
:query datetime modified_since: Only return objects that have changed since the given date. Be careful: This does not

View File

@@ -19,6 +19,8 @@ max_usages integer The maximum num
redeemed (default: 1).
redeemed integer The number of times this voucher already has been
redeemed.
min_usages integer The minimum number of times this voucher must be
redeemed on first usage (default: 1).
valid_until datetime The voucher expiration date (or ``null``).
block_quota boolean If ``true``, quota is blocked for this voucher.
allow_ignore_quota boolean If ``true``, this voucher can be redeemed even if a
@@ -48,10 +50,6 @@ show_hidden_items boolean Only if set to
===================================== ========================== =======================================================
.. versionchanged:: 3.4
The attribute ``seat`` has been added.
Endpoints
---------

View File

@@ -30,12 +30,6 @@ subevent integer ID of the date
===================================== ========================== =======================================================
.. versionchanged:: 1.15
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added as well as a method to send out
vouchers.
Endpoints
---------

View File

@@ -36,10 +36,16 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.order.canceled``
* ``pretix.event.order.reactivated``
* ``pretix.event.order.expired``
* ``pretix.event.order.expirychanged``
* ``pretix.event.order.modified``
* ``pretix.event.order.contact.changed``
* ``pretix.event.order.changed.*``
* ``pretix.event.order.refund.created``
* ``pretix.event.order.refund.created.externally``
* ``pretix.event.order.refund.requested``
* ``pretix.event.order.refund.done``
* ``pretix.event.order.refund.canceled``
* ``pretix.event.order.refund.failed``
* ``pretix.event.order.approved``
* ``pretix.event.order.denied``
* ``pretix.event.checkin``
@@ -50,6 +56,10 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.subevent.added``
* ``pretix.subevent.changed``
* ``pretix.subevent.deleted``
* ``pretix.event.live.activated``
* ``pretix.event.live.deactivated``
* ``pretix.event.testmode.activated``
* ``pretix.event.testmode.deactivated``
Installed plugins might register more valid values.

View File

@@ -92,9 +92,10 @@ If any other status code is returned, we will assume you did not receive the cal
or ``304 Not Modified`` response will be treated as a failure. pretix will not follow any ``301`` or ``302`` redirect
headers and pretix will ignore all other information in your response headers or body.
If we do not receive a status code in the range of ``200`` and ``299``, pretix will retry to deliver for up to three
days with an exponential back off. Therefore, we recommend that you implement your endpoint in a way where calling it
multiple times for the same event due to a perceived error does not do any harm.
If we do not receive a status code in the range of ``200`` and ``299`` or do not receive any response within a 30 second
time frame, pretix will retry to deliver for up to three days with an exponential back off. Therefore, we recommend that
you implement your endpoint in a way where calling it multiple times for the same event due to a perceived error does
not do any harm.
There is only one exception: If status code ``410 Gone`` is returned, we will assume the
endpoint does not exist any more and automatically disable the webhook.

View File

@@ -60,7 +60,13 @@ The exporter class
.. py:attribute:: BaseExporter.event
The default constructor sets this property to the event we are currently
working for.
working for. This will be ``None`` if the exporter is run for multiple
events.
.. py:attribute:: BaseExporter.events
The default constructor sets this property to the list of events to work
on, regardless of whether the exporter is called for one or multiple events.
.. autoattribute:: identifier

View File

@@ -126,6 +126,8 @@ The provider class
.. automethod:: api_payment_details
.. automethod:: api_refund_details
.. automethod:: matching_id
.. automethod:: shred_payment_info
@@ -136,6 +138,10 @@ The provider class
.. autoattribute:: is_meta
.. autoattribute:: execute_payment_needs_user
.. autoattribute:: multi_use_supported
.. autoattribute:: test_mode_message
.. autoattribute:: requires_invoice_immediately

View File

@@ -184,11 +184,6 @@ Most of these methods work identically on :class:`pretix.base.models.TeamAPIToke
Staff sessions
--------------
.. versionchanged:: 1.14
In 1.14, the ``User.is_superuser`` attribute has been deprecated and statically set to return ``False``. Staff
sessions have been newly introduced.
System administrators of a pretix instance are identified by the ``is_staff`` attribute on the user model. By default,
the regular permission rules apply for users with ``is_staff = True``. The only difference is that such users can
temporarily turn on "staff mode" via a button in the user interface that grants them **all permissions** as long as

View File

@@ -91,8 +91,10 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal content ID
title multi-lingual string The content title (required)
internal_name string An optional name that is only used in the backend
content_type string The type of content, valid values are ``webinar``, ``video``, ``livestream``, ``link``, ``file``
url string The location of the digital content
file file A downloadable file. Either ``url`` or ``file`` must be ``null``.
description multi-lingual string A public description of the item. May contain Markdown
syntax and is not required.
available_from datetime The first date time at which this content will be shown
@@ -144,6 +146,7 @@ API Endpoints
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},
@@ -191,6 +194,7 @@ API Endpoints
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},
@@ -229,6 +233,7 @@ API Endpoints
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},
@@ -255,6 +260,7 @@ API Endpoints
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},
@@ -309,6 +315,7 @@ API Endpoints
},
"content_type": "link",
"url": "https://mywebsite.com",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},

10
doc/requirements.rtd.txt Normal file
View File

@@ -0,0 +1,10 @@
sphinx==2.3.*
jinja2==3.0.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
sphinxcontrib-images
sphinxcontrib-spelling==4.*
sphinxemoji
pygments-markdown-lexer
# See https://github.com/rfk/pyenchant/pull/130
git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -66,6 +66,7 @@ iterable
Jimdo
jwt
JWT
JWTs
libpretixprint
libsass
linters
@@ -88,7 +89,9 @@ nginx
nodejs
NotificationType
npm
OIDC
ons
OpenID
optimizations
overpayment
param
@@ -133,6 +136,7 @@ serializer
serializers
sexualized
SQL
SSO
startup
stdout
stylesheet
@@ -159,6 +163,8 @@ untrusted
uptime
username
url
URI
URIs
validator
versa
versioning

View File

@@ -0,0 +1,210 @@
.. _customers:
Customer accounts
=================
By default, pretix only offers guest checkout, i.e. ticket buyers do not sign up and sign back in, but create a new
checkout session every time. In some situations it may be convenient to allow ticket buyers to create
accounts that they can later log in to again. Working with customer accounts is even required for some advanced
use cases such as described in the :ref:`seasontickets` article.
Enabling customer accounts
--------------------------
To enable customer accounts, head to your organizer page in the backend and then select "Settings" → "General" →
"Customer accounts" and turn on the checkbox "Allow customers to create accounts".
Using the other settings on the same tab you can fine-tune how the customer account system behaves:
.. thumbnail:: ../../screens/organizer/edit_customer.png
:align: center
:class: screenshot
Allow customers to log in with email address and password
In all simple setups, this option should be checked. If this checkbox is removed, it is impossible to log in or
sign up unless you connect a SSO provider (see below).
Match orders based on email address
If this option is selected, customers will see orders made with their email address within their account even if
they did not make those orders while logged in.
Name format, Allowed titles
This controls how we'll ask your customers for their name, similar to the respective settings on event level.
Managing customer accounts
--------------------------
After customer accounts have been enabled, you will find a new menu option "Customer accounts" in the organizer-level
main menu. The first sub-item, "Customers", allows you to search and inspect the list of your customer accounts, as well
as to create a new customer account from the backend:
.. thumbnail:: ../../screens/organizer/customers.png
:align: center
:class: screenshot
If you click on a customer ID, you can see all details of this customer account, including registration information,
active memberships, past ticket orders, and account history:
.. thumbnail:: ../../screens/organizer/customer.png
:align: center
:class: screenshot
You can also perform various actions from this view, such as:
- Send a password reset link
- Change registration information
- Anonymize the customer account (does not anonymize connected orders)
When creating or changing a customer, you will be presented with the following form:
.. thumbnail:: ../../screens/organizer/customer_edit.png
:align: center
:class: screenshot
Most fields, such as name, e-mail address, phone number, and language should be self-explanatory. The following fields
might require some explanation:
Account active
If this checkbox is removed, the customer will not be able to log in.
External identifier
This field can be used to cross-reference your customer database with other sources. For example, if the customer
already has a number in another system, you can insert that number here. This can be especially powerful if you
use our API for synchronization with an external system.
Verified email address
This checkbox signifies whether you have verified that this customer in fact controls the given email address.
This will automatically be checked after a successful registration or after a successful password reset. Before it
is checked, the customer will not be able to log in. You should usually not modify this field manually.
Notes
Entries in this field will only be visible to you and your team, not to the customer.
Single-Sign-On (SSO)
--------------------
"Single-Sign-On" (SSO) is a technical term for a situation in which a person can log in to multiple systems using just
one login. This can be convenient if you have multiple applications that are exposed to your customers: They won't have
to remember multiple passwords or understand how your application landscape is structured, they can just always log in
with the same credentials whenever they see your brand.
In this scenario, pretix can be **either** the "SSO provider" **or** the "SSO client".
If pretix is the SSO provider, pretix will be the central source of truth for your customer accounts and your other
applications can connect to pretix to use pretix's login functionality.
If pretix is the SSO client, one of your existing systems will be the source of truth for the customer accounts and
pretix will use that system's login functionality.
All SSO support for customer accounts in pretix is currently built on the `OpenID Connect`_ standard, a modern and
widely accepted standard for SSO in all industries.
Connecting SSO clients (pretix as the SSO provider)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To connect an external application as a SSO client, go to "Customer accounts" → "SSO clients" → "Create a new SSO client"
in your organizer account.
.. thumbnail:: ../../screens/organizer/customer_ssoclient_add.png
:align: center
:class: screenshot
You will need to fill out the following fields:
Active
If this checkbox is removed, the SSO client can not be used.
Application name
The name of your external application, e.g. "digital event marketplace".
Client type
For a server-side application which is able to store a secret that will be inaccessible to end users, chose
"confidential". For a client-side application, such as many mobile apps, choose "public".
Grant type
This value depends on the OpenID Connect implementation of your software.
Redirection URIs
One or multiple URIs that the user might be redirected to after the successful or failed login.
Allowed access scopes
The types of data the SSO client may access about the customer.
After you submitted all data, you will receive a client ID as well as a client secret. The client secret is shown
in the green success message and will only ever be shown once. If you need it again, use the option "Invalidate old
client secret and generate a new one".
You will need the client ID and client secret to configure your external application. The application will also likely
need some other information from you, such as your **issuer URI**. If you use pretix Hosted and your organizer account
does not have a custom domain, your issuer will be ``https://pretix.eu/myorgname``, where ``myorgname`` is the short
form of your organizer account. If you use a custom domain, such as ``tickets.mycompany.net``, then your issuer will be
``https://tickets.mycompany.net``.
Technical details
"""""""""""""""""
We implement `OpenID Connect Core 1.0`_, except for some optional parts that do not make sense for pretix or bring no
additional value. For example, we do not currently support encrypted tokens, offline access, refresh tokens, or passing
request parameters as JWTs.
We implement the provider metadata section from `OpenID Connect Discovery 1.0`_. You can find the endpoint relative
to the issuer URI as described above, for example ``http://pretix.eu/demo/.well-known/openid-configuration``.
We implement all three OpenID Connect Core flows:
- Authorization Code Flow (response type ``code``)
- Implicit Flow (response types ``id_token token`` and ``id_token``)
- Hybrid Flow (response types ``code id_token``, ``code id_token token``, and ``code token``)
We implement the response modes ``query`` and ``fragment``.
We currently offer the following scopes: ``openid``, ``profile``, ``email``, ``phone``
As well as the following standardized claims: ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time``, ``nonce``, ``c_hash``,
``at_hash``, ``sub``, ``locale``, ``name``, ``given_name``, ``family_name``, ``middle_name``, ``nickname``, ``email``,
``email_verified``, ``phone_number``.
The various endpoints are located relative to the issuer URI as described above:
- Authorization: ``<issuer>/oauth2/v1/authorize``
- Token: ``<issuer>/oauth2/v1/token``
- User info: ``<issuer>/oauth2/v1/userinfo``
- Keys: ``<issuer>/oauth2/v1/keys``
We currently do not reproduce their documentation here as they follow the OpenID Connect and OAuth specifications
without any special behavior.
Connecting SSO providers (pretix as the SSO client)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To connect an external application as a SSO client, go to "Customer accounts" → "SSO providers" → "Create a new SSO provider"
in your organizer account.
.. thumbnail:: ../../screens/organizer/customer_ssoprovider_add.png
:align: center
:class: screenshot
The "Provider name" and "Login button label" is what we'll use to show the new login option to the user. For the actual
connection, we will require information such as the issuer URL, client ID, client secret, scope, and field (or claim)
names that you will receive from your SSO provider.
.. note::
If you want your customers to *only* use your SSO provider, it makes sense to turn off the "Allow customers to log in
with email address and password" settings option (see above).
Technical details
"""""""""""""""""
We assume that SSO providers fulfill the following requirements:
- Implementation according to `OpenID Connect Core 1.0`_.
- Published meta-data document at ``<issuer>/.well-known/openid-configuration`` as specified in `OpenID Connect Discovery 1.0`_.
- Support for Authorization code flow (``response_type=code``) with ``response_mode=query``.
- Support for client authentication using client ID and client secret and without public key cryptography.
.. _OpenID Connect: https://en.wikipedia.org/wiki/OpenID#OpenID_Connect_(OIDC)
.. _OpenID Connect Core 1.0: https://openid.net/specs/openid-connect-core-1_0.html
.. _OpenID Connect Discovery 1.0: https://openid.net/specs/openid-connect-discovery-1_0.html

View File

@@ -411,7 +411,7 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
};
</script>
If you use ```analytics.js` (Universal Analytics)::
If you use ``analytics.js`` (Universal Analytics)::
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
@@ -447,8 +447,4 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
</script>
.. versionchanged:: 3.6
Dynamically opening the widget has been added in pretix 3.6.
.. _Let's Encrypt: https://letsencrypt.org/

View File

@@ -12,6 +12,7 @@ wanting to use pretix to sell tickets.
events/settings
events/structureguide
events/widget
customers/index
events/giftcards
faq
markdown

View File

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

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "4.13.0.dev0"
__version__ = "4.16.0.dev0"

View File

@@ -46,6 +46,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('GET', 'api-v1:device.info'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -80,6 +81,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('GET', 'api-v1:device.info'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -112,6 +114,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('GET', 'api-v1:device.info'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -145,6 +148,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('GET', 'api-v1:device.info'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -192,6 +196,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'),
('GET', 'plugins:pretix_posbackend:poscashier-list'),
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
('POST', 'plugins:pretix_posbackend:stripeterminal.paymentintent'),
('PUT', 'plugins:pretix_posbackend:file.upload'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:event.settings'),

View File

@@ -19,11 +19,17 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
import ujson
from rest_framework import exceptions
from rest_framework.response import Response
from rest_framework.views import exception_handler, status
from pretix.base.services.locking import LockTimeoutException
logger = logging.getLogger(__name__)
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
@@ -37,4 +43,7 @@ def custom_exception_handler(exc, context):
}
)
if isinstance(exc, exceptions.APIException):
logger.info(f'API Exception [{exc.status_code}]: {ujson.dumps(exc.detail)}')
return response

View File

@@ -0,0 +1,29 @@
# Generated by Django 3.2.12 on 2022-09-13 14:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0218_checkinlist_addon_match'),
('pretixapi', '0007_alter_webhookcall_target_url'),
]
operations = [
migrations.CreateModel(
name='WebHookCallRetry',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('retry_not_before', models.DateTimeField(auto_now_add=True)),
('retry_count', models.PositiveIntegerField(default=0)),
('action_type', models.CharField(max_length=255)),
('logentry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_retries', to='pretixbase.logentry')),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='retries', to='pretixapi.webhook')),
],
options={
'unique_together': {('webhook', 'logentry')},
},
),
]

View File

@@ -0,0 +1,77 @@
# Generated by Django 3.2.16 on 2022-12-17 18:47
import uuid
import django.db.models.deletion
import oauth2_provider.generators
import oauth2_provider.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0226_itemvariationmetavalue'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('pretixapi', '0008_webhookcallretry'),
]
run_before = [
('oauth2_provider', '0002_auto_20190406_1805'),
]
operations = [
migrations.AddField(
model_name='oauthapplication',
name='algorithm',
field=models.CharField(default='', max_length=5),
),
migrations.AddField(
model_name='oauthgrant',
name='claims',
field=models.TextField(default=''),
preserve_default=False,
),
migrations.AddField(
model_name='oauthgrant',
name='code_challenge',
field=models.CharField(default='', max_length=128),
),
migrations.AddField(
model_name='oauthgrant',
name='code_challenge_method',
field=models.CharField(default='', max_length=10),
),
migrations.AddField(
model_name='oauthgrant',
name='nonce',
field=models.CharField(default='', max_length=255),
),
migrations.AlterField(
model_name='oauthapplication',
name='client_secret',
field=oauth2_provider.models.ClientSecretField(db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255),
),
migrations.CreateModel(
name='OAuthIDToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('jti', models.UUIDField(default=uuid.uuid4, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField()),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('organizers', models.ManyToManyField(to='pretixbase.Organizer')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pretixapi_oauthidtoken', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='oauthaccesstoken',
name='id_token',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to='pretixapi.oauthidtoken'),
),
]

View File

@@ -29,8 +29,8 @@ from oauth2_provider.generators import (
generate_client_id, generate_client_secret,
)
from oauth2_provider.models import (
AbstractAccessToken, AbstractApplication, AbstractGrant,
AbstractRefreshToken,
AbstractAccessToken, AbstractApplication, AbstractGrant, AbstractIDToken,
AbstractRefreshToken, ClientSecretField,
)
from oauth2_provider.validators import URIValidator
@@ -46,7 +46,7 @@ class OAuthApplication(AbstractApplication):
verbose_name=_("Client ID"),
max_length=100, unique=True, default=generate_client_id, db_index=True
)
client_secret = models.CharField(
client_secret = ClientSecretField(
verbose_name=_("Client secret"),
max_length=255, blank=False, default=generate_client_secret, db_index=True
)
@@ -67,12 +67,26 @@ class OAuthGrant(AbstractGrant):
redirect_uri = models.CharField(max_length=2500) # Only 255 in AbstractGrant, which caused problems
class OAuthIDToken(AbstractIDToken):
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE,
)
organizers = models.ManyToManyField('pretixbase.Organizer')
class OAuthAccessToken(AbstractAccessToken):
source_refresh_token = models.OneToOneField(
# unique=True implied by the OneToOneField
'OAuthRefreshToken', on_delete=models.SET_NULL, blank=True, null=True,
related_name="refreshed_access_token"
)
id_token = models.OneToOneField(
OAuthIDToken,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="access_token",
)
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE, blank=True, null=True,
)
@@ -133,6 +147,18 @@ class WebHookCall(models.Model):
ordering = ("-datetime",)
class WebHookCallRetry(models.Model):
id = models.BigAutoField(primary_key=True)
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='retries')
logentry = models.ForeignKey('pretixbase.LogEntry', on_delete=models.CASCADE, related_name='webhook_retries')
retry_not_before = models.DateTimeField(auto_now_add=True)
retry_count = models.PositiveIntegerField(default=0)
action_type = models.CharField(max_length=255)
class Meta:
unique_together = (('webhook', 'logentry'),)
class ApiCall(models.Model):
idempotency_key = models.CharField(max_length=190, db_index=True)
auth_hash = models.CharField(max_length=190, db_index=True)

View File

@@ -23,8 +23,7 @@ import os
from datetime import timedelta
from django.core.files import File
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.db.models import prefetch_related_objects
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
from rest_framework import serializers
@@ -34,7 +33,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
)
from pretix.base.models import Quota, Seat, Voucher
from pretix.base.models import Seat, Voucher
from pretix.base.models.orders import CartPosition
@@ -52,148 +51,18 @@ class CartPositionSerializer(I18nAwareModelSerializer):
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers', 'seat')
'answers', 'seat', 'is_bundled')
class CartPositionCreateSerializer(I18nAwareModelSerializer):
class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
includes_tax = serializers.BooleanField(required=False, allow_null=True)
voucher = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel', 'voucher')
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)
)
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(
gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(validated_data.get('item'))
)
)
for quota in new_quotas:
avail = quota.availability(_cache=self.context['quota_cache'])
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
raise ValidationError(
gettext_lazy('There is not enough quota available on quota "{}" to perform '
'the operation.').format(
quota.name
)
)
for quota in new_quotas:
oldsize = self.context['quota_cache'][quota.pk][1]
newsize = oldsize - 1 if oldsize is not None else None
self.context['quota_cache'][quota.pk] = (
Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
newsize
)
attendee_name = validated_data.pop('attendee_name', '')
if attendee_name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
if validated_data.get('seat'):
if not seated:
raise ValidationError('The specified product does not allow to choose a seat.')
try:
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError('The specified seat does not exist.')
except Seat.MultipleObjectsReturned:
raise ValidationError('The specified seat ID is not unique.')
else:
validated_data['seat'] = seat
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
if validated_data.get('voucher'):
try:
voucher = self.context['event'].vouchers.get(code__iexact=validated_data.get('voucher'))
except Voucher.DoesNotExist:
raise ValidationError('The specified voucher does not exist.')
if voucher and not voucher.applies_to(validated_data.get('item'), validated_data.get('variation')):
raise ValidationError('The specified voucher is not valid for the given item and variation.')
if voucher and voucher.seat and voucher.seat != validated_data.get('seat'):
raise ValidationError('The specified voucher is not valid for this seat.')
if voucher and voucher.subevent_id and (not validated_data.get('subevent') or voucher.subevent_id != validated_data['subevent'].pk):
raise ValidationError('The specified voucher is not valid for this subevent.')
if voucher.valid_until is not None and voucher.valid_until < now():
raise ValidationError('The specified voucher is expired.')
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=voucher) & Q(event=self.context['event']) & Q(expires__gte=now())
)
cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count
if v_avail < 1:
raise ValidationError('The specified voucher has already been used the maximum number of times.')
validated_data['voucher'] = voucher
if validated_data.get('seat'):
if not validated_data['seat'].is_available(
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
):
raise ValidationError(
gettext_lazy('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name))
validated_data.pop('sales_channel')
# todo: does this make sense?
validated_data['custom_price_input'] = validated_data['price']
# todo: listed price, etc?
# currently does not matter because there is no way to transform an API cart position into an order that keeps
# prices, cart positions are just quota/voucher placeholders
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = cp.answers.create(**answ_data, answer='')
answ.file.save(os.path.basename(an.name), an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
an.close()
else:
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.')
return cid
fields = ('item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'includes_tax', 'answers')
def validate_item(self, item):
if item.event != self.context['event']:
@@ -240,4 +109,180 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if not data.get('expires'):
data['expires'] = now() + timedelta(
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
)
quotas_for_item_cache = self.context.get('quotas_for_item_cache', {})
quotas_for_variation_cache = self.context.get('quotas_for_variation_cache', {})
seated = data.get('item').seat_category_mappings.filter(subevent=data.get('subevent')).exists()
if data.get('seat'):
if not seated:
raise ValidationError({'seat': ['The specified product does not allow to choose a seat.']})
try:
seat = self.context['event'].seats.get(seat_guid=data['seat'], subevent=data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError({'seat': ['The specified seat does not exist.']})
except Seat.MultipleObjectsReturned:
raise ValidationError({'seat': ['The specified seat ID is not unique.']})
else:
data['seat'] = seat
elif seated:
raise ValidationError({'seat': ['The specified product requires to choose a seat.']})
if data.get('voucher'):
try:
voucher = self.context['event'].vouchers.get(code__iexact=data['voucher'])
except Voucher.DoesNotExist:
raise ValidationError({'voucher': ['The specified voucher does not exist.']})
if voucher and not voucher.applies_to(data['item'], data.get('variation')):
raise ValidationError({'voucher': ['The specified voucher is not valid for the given item and variation.']})
if voucher and voucher.seat and voucher.seat != data.get('seat'):
raise ValidationError({'voucher': ['The specified voucher is not valid for this seat.']})
if voucher and voucher.subevent_id and (not data.get('subevent') or voucher.subevent_id != data['subevent'].pk):
raise ValidationError({'voucher': ['The specified voucher is not valid for this subevent.']})
if voucher.valid_until is not None and voucher.valid_until < now():
raise ValidationError({'voucher': ['The specified voucher is expired.']})
data['voucher'] = voucher
if not data.get('voucher') or (not data['voucher'].allow_ignore_quota and not data['voucher'].block_quota):
if data.get('variation'):
if data['variation'].pk not in quotas_for_variation_cache:
quotas_for_variation_cache[data['variation'].pk] = data['variation'].quotas.filter(subevent=data.get('subevent'))
data['_quotas'] = quotas_for_variation_cache[data['variation'].pk]
else:
if data['item'].pk not in quotas_for_item_cache:
quotas_for_item_cache[data['item'].pk] = data['item'].quotas.filter(subevent=data.get('subevent'))
data['_quotas'] = quotas_for_item_cache[data['item'].pk]
if len(data['_quotas']) == 0:
raise ValidationError(
gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(data.get('item'))
)
)
else:
data['_quotas'] = []
return data
def create(self, validated_data):
validated_data.pop('_quotas')
answers_data = validated_data.pop('answers')
attendee_name = validated_data.pop('attendee_name', '')
if attendee_name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
# todo: does this make sense?
validated_data['custom_price_input'] = validated_data['price']
# todo: listed price, etc?
# currently does not matter because there is no way to transform an API cart position into an order that keeps
# prices, cart positions are just quota/voucher placeholders
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = cp.answers.create(**answ_data, answer='')
answ.file.save(os.path.basename(an.name), an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
an.close()
else:
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
return cp
class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
expires = serializers.DateTimeField(required=False)
addons = BaseCartPositionCreateSerializer(many=True, required=False)
bundled = BaseCartPositionCreateSerializer(many=True, required=False)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
voucher = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = BaseCartPositionCreateSerializer.Meta.fields + (
'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher'
)
def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.')
return cid
def create(self, validated_data):
validated_data.pop('sales_channel')
addons_data = validated_data.pop('addons', None)
bundled_data = validated_data.pop('bundled', None)
cp = super().create(validated_data)
if addons_data:
for addon_data in addons_data:
addon_data['addon_to'] = cp
addon_data['is_bundled'] = False
addon_data['cart_id'] = cp.cart_id
super().create(addon_data)
if bundled_data:
for bundle_data in bundled_data:
bundle_data['addon_to'] = cp
bundle_data['is_bundled'] = True
bundle_data['cart_id'] = cp.cart_id
super().create(bundle_data)
return cp
def validate(self, data):
data = super().validate(data)
# This is currently only a very basic validation of add-ons and bundled products, we don't validate their number
# or price. We can always go stricter, as the endpoint is documented as experimental.
# However, this serializer should always be *at least* as strict as the order creation serializer.
if data.get('item') and data.get('addons'):
prefetch_related_objects([data['item']], 'addons')
for sub_data in data['addons']:
if not any(a.addon_category_id == sub_data['item'].category_id for a in data['item'].addons.all()):
raise ValidationError({
'addons': [
'The product "{prod}" can not be used as an add-on product for "{main}".'.format(
prod=str(sub_data['item']),
main=str(data['item']),
)
]
})
if data.get('item') and data.get('bundled'):
prefetch_related_objects([data['item']], 'bundles')
for sub_data in data['bundled']:
if not any(
a.bundled_item_id == sub_data['item'].pk and
a.bundled_variation_id == (sub_data['variation'].pk if sub_data.get('variation') else None)
for a in data['item'].bundles.all()
):
raise ValidationError({
'bundled': [
'The product "{prod}" can not be used as an bundled product for "{main}".'.format(
prod=str(sub_data['item']),
main=str(data['item']),
)
]
})
return data

View File

@@ -411,7 +411,8 @@ class CloneEventSerializer(EventSerializer):
has_subevents = validated_data.pop('has_subevents', None)
tz = validated_data.pop('timezone', None)
sales_channels = validated_data.pop('sales_channels', None)
new_event = super().create(validated_data)
date_admission = validated_data.pop('date_admission', None)
new_event = super().create({**validated_data, 'plugins': None})
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
new_event.copy_data_from(event)
@@ -426,6 +427,10 @@ class CloneEventSerializer(EventSerializer):
new_event.sales_channels = sales_channels
if has_subevents is not None:
new_event.has_subevents = has_subevents
if has_subevents is not None:
new_event.has_subevents = has_subevents
if date_admission is not None:
new_event.date_admission = date_admission
new_event.save()
if tz:
new_event.settings.timezone = tz
@@ -755,6 +760,9 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_logo_image',
'cancel_allow_user',
'cancel_allow_user_until',
'cancel_allow_user_unpaid_keep',
'cancel_allow_user_unpaid_keep_fees',
'cancel_allow_user_unpaid_keep_percentage',
'cancel_allow_user_paid',
'cancel_allow_user_paid_until',
'cancel_allow_user_paid_keep',

View File

@@ -23,6 +23,8 @@ from django import forms
from django.http import QueryDict
from rest_framework import serializers
from pretix.base.exporter import OrganizerLevelExportMixin
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):
@@ -49,7 +51,6 @@ simple_mappings = (
(forms.EmailField, serializers.EmailField, ()),
(forms.UUIDField, serializers.UUIDField, ()),
(forms.URLField, serializers.URLField, ()),
(forms.NullBooleanField, serializers.NullBooleanField, ()),
(forms.BooleanField, serializers.BooleanField, ()),
)
@@ -87,7 +88,7 @@ class JobRunSerializer(serializers.Serializer):
ex = kwargs.pop('exporter')
events = kwargs.pop('events', None)
super().__init__(*args, **kwargs)
if events is not None:
if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
self.fields["events"] = serializers.SlugRelatedField(
queryset=events,
required=True,
@@ -106,6 +107,12 @@ class JobRunSerializer(serializers.Serializer):
)
break
if isinstance(v, forms.NullBooleanField):
self.fields[k] = serializers.BooleanField(
required=v.required,
allow_null=True,
validators=v.validators,
)
if isinstance(v, forms.ModelMultipleChoiceField):
self.fields[k] = PrimaryKeyRelatedField(
queryset=v.queryset,

View File

@@ -47,13 +47,14 @@ from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
Question, QuestionOption, Quota,
ItemVariationMetaValue, Question, QuestionOption, Quota,
)
class InlineItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
class Meta:
model = ItemVariation
@@ -61,16 +62,23 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.parent.parent.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
class ItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
class Meta:
model = ItemVariation
@@ -78,12 +86,63 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
variation = ItemVariation.objects.create(**validated_data)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
ItemVariationMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
variation=variation
)
return variation
@cached_property
def item_meta_properties(self):
return {
p.name: p for p in self.context['request'].event.item_meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
variation = super().update(instance, validated_data)
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in variation.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.item_meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
variation.meta_values.create(
property=self.item_meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
return variation
class InlineItemBundleSerializer(serializers.ModelSerializer):
class Meta:
@@ -184,6 +243,8 @@ class ItemSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['default_price'].allow_null = False
self.fields['default_price'].required = True
if not self.read_only:
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
@@ -261,9 +322,19 @@ class ItemSerializer(I18nAwareModelSerializer):
for variation_data in variations_data:
require_membership_types = variation_data.pop('require_membership_types', [])
var_meta_data = variation_data.pop('meta_data', {})
v = ItemVariation.objects.create(item=item, **variation_data)
if require_membership_types:
v.require_membership_types.add(*require_membership_types)
if var_meta_data is not None:
for key, value in var_meta_data.items():
ItemVariationMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
variation=v
)
for addon_data in addons_data:
ItemAddOn.objects.create(base_item=item, **addon_data)
for bundle_data in bundles_data:

View File

@@ -29,6 +29,7 @@ import pycountry
from django.conf import settings
from django.core.files import File
from django.db.models import F, Q
from django.utils.encoding import force_str
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
from django_countries.fields import Country
@@ -61,14 +62,25 @@ from pretix.base.services.pricing import (
)
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries
from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__)
class CompatibleCountryField(serializers.Field):
countries = CachedCountries()
default_error_messages = {
'invalid_choice': gettext_lazy('"{input}" is not a valid choice.')
}
def to_internal_value(self, data):
return {self.field_name: Country(data)}
country = self.countries.alpha2(data)
if data and not country:
country = self.countries.by_name(force_str(data))
if not country:
self.fail("invalid_choice", input=data)
return {self.field_name: Country(country)}
def to_representation(self, instance: InvoiceAddress):
if instance.country:
@@ -359,10 +371,19 @@ class PdfDataSerializer(serializers.Field):
for k, v in ev._cached_meta_data.items():
res['meta:' + k] = v
if not hasattr(instance.item, '_cached_meta_data'):
instance.item._cached_meta_data = instance.item.meta_data
for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + k] = v
if instance.variation_id:
print(instance, instance.variation, instance.variation_id, instance.item)
if not hasattr(instance.variation, '_cached_meta_data'):
instance.variation.item = instance.item # saves some database lookups
instance.variation._cached_meta_data = instance.variation.meta_data
print(instance.variation._cached_meta_data.items())
for k, v in instance.variation._cached_meta_data.items():
res['itemmeta:' + k] = v
else:
if not hasattr(instance.item, '_cached_meta_data'):
instance.item._cached_meta_data = instance.item.meta_data
for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + k] = v
res['images'] = {}
@@ -410,13 +431,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
read_only_fields = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
'seat', 'canceled'
'seat', 'canceled', 'discount',
)
def __init__(self, *args, **kwargs):
@@ -553,12 +574,22 @@ class OrderPaymentSerializer(I18nAwareModelSerializer):
'details')
class RefundDetailsField(serializers.Field):
def to_representation(self, value: OrderRefund):
pp = value.payment_provider
if not pp:
return {}
return pp.api_refund_details(value)
class OrderRefundSerializer(I18nAwareModelSerializer):
payment = SlugRelatedField(slug_field='local_id', read_only=True)
details = RefundDetailsField(source='*', allow_null=True, read_only=True)
class Meta:
model = OrderRefund
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider')
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider',
'details')
class OrderURLField(serializers.URLField):
@@ -600,6 +631,23 @@ class OrderSerializer(I18nAwareModelSerializer):
if not self.context['pdf_data']:
self.fields['positions'].child.fields.pop('pdf_data', None)
includes = set(self.context['include'])
if includes:
for fname, field in list(self.fields.items()):
if fname in includes:
continue
elif hasattr(field, 'child'):
found_any = False
for childfname, childfield in list(field.child.fields.items()):
if f'{fname}.{childfname}' not in includes:
field.child.fields.pop(childfname)
else:
found_any = True
if not found_any:
self.fields.pop(fname)
else:
self.fields.pop(fname)
for exclude_field in self.context['exclude']:
p = exclude_field.split('.')
if p[0] in self.fields:
@@ -721,7 +769,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
def __init__(self, *args, **kwargs):
@@ -1086,6 +1134,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if pos_data.get('addon_to'):
errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
continue
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
@@ -1281,6 +1333,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if not simulate:
for cp in delete_cps:
if cp.addon_to_id:
continue
cp.addons.all().delete()
cp.delete()
order.total = sum([p.price for p in pos_map.values()])

View File

@@ -74,13 +74,19 @@ class CustomerSerializer(I18nAwareModelSerializer):
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified', 'notes')
def update(self, instance, validated_data):
if instance and instance.provider_id:
validated_data['external_identifier'] = instance.external_identifier
return super().update(instance, validated_data)
class CustomerCreateSerializer(CustomerSerializer):
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
password = serializers.CharField(write_only=True, required=False, allow_null=True)
class Meta:
model = Customer
fields = CustomerSerializer.Meta.fields + ('send_email',)
fields = CustomerSerializer.Meta.fields + ('send_email', 'password')
class MembershipTypeSerializer(I18nAwareModelSerializer):
@@ -113,20 +119,21 @@ class GiftCardSerializer(I18nAwareModelSerializer):
def validate(self, data):
data = super().validate(data)
s = data['secret']
qs = GiftCard.objects.filter(
secret=s
).filter(
Q(issuer=self.context["organizer"]) | Q(
issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(
{'secret': _(
'A gift card with the same secret already exists in your or an affiliated organizer account.')}
if 'secret' in data:
s = data['secret']
qs = GiftCard.objects.filter(
secret=s
).filter(
Q(issuer=self.context["organizer"]) | Q(
issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(
{'secret': _(
'A gift card with the same secret already exists in your or an affiliated organizer account.')}
)
return data
class Meta:
@@ -282,6 +289,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class OrganizerSettingsSerializer(SettingsSerializer):
default_fields = [
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',
'invoice_regenerate_allowed',
'contact_mail',

View File

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

View File

@@ -138,6 +138,7 @@ urlpatterns = [
re_path(r"^device/update$", device.UpdateView.as_view(), name="device.update"),
re_path(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
re_path(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
re_path(r"^device/info$", device.InfoView.as_view(), name="device.info"),
re_path(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
re_path(r"^idempotency_query$", idempotency.IdempotencyQueryView.as_view(), name="idempotency.query"),
re_path(r"^upload$", upload.UploadView.as_view(), name="upload"),

View File

@@ -19,19 +19,28 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import Counter
from typing import List
from django.db import transaction
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.serializers import as_serializer_error
from pretix.api.serializers.cart import (
CartPositionCreateSerializer, CartPositionSerializer,
)
from pretix.base.models import CartPosition
from pretix.base.services.cart import (
_get_quota_availability, _get_voucher_availability, error_messages,
)
from pretix.base.services.locking import NoLockManager
@@ -54,18 +63,17 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['quota_cache'] = {}
ctx['quotas_for_item_cache'] = {}
ctx['quotas_for_variation_cache'] = {}
return ctx
def create(self, request, *args, **kwargs):
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
ctx = self.get_serializer_context()
serializer = CartPositionCreateSerializer(data=request.data, context=ctx)
serializer.is_valid(raise_exception=True)
with transaction.atomic(), self.request.event.lock():
self.perform_create(serializer)
cp = serializer.instance
serializer = CartPositionSerializer(cp, context=serializer.context)
results = self._create(serializers=[serializer], raise_exception=True, ctx=ctx)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
return Response(results[0]['data'], status=status.HTTP_201_CREATED, headers=headers)
@action(detail=False, methods=['POST'])
def bulk_create(self, request, *args, **kwargs):
@@ -73,42 +81,163 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
ctx = self.get_serializer_context()
with transaction.atomic():
serializers = [
CartPositionCreateSerializer(data=d, context=ctx)
for d in request.data
]
lockfn = self.request.event.lock
if not any(s.is_valid(raise_exception=False) for s in serializers):
lockfn = NoLockManager
results = []
with lockfn():
for s in serializers:
if s.is_valid(raise_exception=False):
try:
cp = s.save()
except ValidationError as e:
results.append({
'success': False,
'data': None,
'errors': {api_settings.NON_FIELD_ERRORS_KEY: e.detail},
})
else:
results.append({
'success': True,
'data': CartPositionSerializer(cp, context=ctx).data,
'errors': None,
})
else:
results.append({
'success': False,
'data': None,
'errors': s.errors,
})
serializers = [
CartPositionCreateSerializer(data=d, context=ctx)
for d in request.data
]
results = self._create(serializers=serializers, raise_exception=False, ctx=ctx)
return Response({'results': results}, status=status.HTTP_200_OK)
def perform_create(self, serializer):
serializer.save()
raise NotImplementedError()
@transaction.atomic()
def perform_destroy(self, instance):
instance.addons.all().delete()
instance.delete()
def _require_locking(self, quota_diff, voucher_use_diff, seat_diff):
if voucher_use_diff or seat_diff:
# If any vouchers or seats are used, we lock to make sure we don't redeem them to often
return True
if quota_diff and any(q.size is not None for q in quota_diff):
# If any quotas are affected that are not unlimited, we lock
return True
return False
@cached_property
def _create_default_cart_id(self):
cid = "{}@api".format(get_random_string(48))
while CartPosition.objects.filter(cart_id=cid).exists():
cid = "{}@api".format(get_random_string(48))
return cid
def _create(self, serializers: List[CartPositionCreateSerializer], ctx, raise_exception=False):
voucher_use_diff = Counter()
quota_diff = Counter()
seat_diff = Counter()
results = [{} for pserializer in serializers]
for i, pserializer in enumerate(serializers):
if not pserializer.is_valid(raise_exception=raise_exception):
results[i] = {
'success': False,
'data': None,
'errors': pserializer.errors,
}
for pserializer in serializers:
if pserializer.errors:
continue
validated_data = pserializer.validated_data
if not validated_data.get('cart_id'):
validated_data['cart_id'] = self._create_default_cart_id
if validated_data.get('voucher'):
voucher_use_diff[validated_data['voucher']] += 1
if validated_data.get('seat'):
seat_diff[validated_data['seat']] += 1
for q in validated_data['_quotas']:
quota_diff[q] += 1
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
for q in sub_data['_quotas']:
quota_diff[q] += 1
seats_seen = set()
lockfn = NoLockManager
if self._require_locking(quota_diff, voucher_use_diff, seat_diff):
lockfn = self.request.event.lock
with lockfn() as now_dt, transaction.atomic():
vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
self.request.event,
voucher_use_diff,
now_dt,
exclude_position_ids=[],
)
quotas_ok = _get_quota_availability(quota_diff, now_dt)
for i, pserializer in enumerate(serializers):
if results[i]:
continue
try:
validated_data = pserializer.validated_data
if validated_data.get('seat'):
# Assumption: Add-ons currently can't have seats
if validated_data['seat'] in seats_seen:
raise ValidationError(error_messages['seat_multiple'])
seats_seen.add(validated_data['seat'])
quotas_needed = Counter()
for q in validated_data['_quotas']:
quotas_needed[q] += 1
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
for q in sub_data['_quotas']:
quotas_needed[q] += 1
for q, needed in quotas_needed.items():
if quotas_ok[q] < needed:
raise ValidationError(
_('There is not enough quota available on quota "{}" to perform the operation.').format(
q.name
)
)
if validated_data.get('voucher'):
# Assumption: Add-ons currently can't have vouchers, thus we only need to check the main voucher
if vouchers_ok[validated_data['voucher']] < 1:
raise ValidationError(
{'voucher': [_('The specified voucher has already been used the maximum number of times.')]}
)
if validated_data.get('seat'):
# Assumption: Add-ons currently can't have seats, thus we only need to check the main product
if not validated_data['seat'].is_available(
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
):
raise ValidationError(
{'seat': [_('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name)]}
)
for q, needed in quotas_needed.items():
quotas_ok[q] -= needed
if validated_data.get('voucher'):
vouchers_ok[validated_data['voucher']] -= 1
if any(qa < 0 for qa in quotas_ok.values()):
# Safeguard, should never happen because of conditions above
raise ValidationError(error_messages['unavailable'])
cp = pserializer.create(validated_data)
d = CartPositionSerializer(cp, context=ctx).data
addons = sorted(cp.addons.all(), key=lambda a: a.pk) # order of creation, safe since they are created in the same transaction
d['addons'] = CartPositionSerializer([a for a in addons if not a.is_bundled], many=True, context=ctx).data
d['bundled'] = CartPositionSerializer([a for a in addons if a.is_bundled], many=True, context=ctx).data
results[i] = {
'success': True,
'data': d,
'errors': None,
}
except ValidationError as e:
if raise_exception:
raise
results[i] = {
'success': False,
'data': None,
'errors': as_serializer_error(e),
}
return results

View File

@@ -29,7 +29,9 @@ from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix import __version__
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.views.version import numeric_version
from pretix.base.models import CheckinList, Device, SubEvent
from pretix.base.models.devices import Gate, generate_api_token
@@ -151,6 +153,24 @@ class RevokeKeyView(APIView):
return Response(serializer.data)
class InfoView(APIView):
authentication_classes = (DeviceTokenAuthentication,)
def get(self, request, format=None):
device = request.auth
serializer = DeviceSerializer(device)
return Response({
'device': serializer.data,
'server': {
'version': {
'pretix': __version__,
'pretix_numeric': numeric_version(__version__),
}
}
})
class EventSelectionView(APIView):
authentication_classes = (DeviceTokenAuthentication,)

View File

@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import django_filters
from django.conf import settings
from django.db import transaction
from django.db.models import Prefetch, ProtectedError, Q
from django.utils.timezone import now
@@ -241,13 +242,17 @@ class EventViewSet(viewsets.ModelViewSet):
except Event.DoesNotExist:
raise ValidationError('Event to copy from was not found')
# Ensure that .installed() is only called when we NOT clone
plugins = serializer.validated_data.pop('plugins', None)
serializer.validated_data['plugins'] = None
new_event = serializer.save(organizer=self.request.organizer)
if copy_from:
new_event.copy_data_from(copy_from)
if 'plugins' in serializer.validated_data:
new_event.set_active_plugins(serializer.validated_data['plugins'])
if plugins is not None:
new_event.set_active_plugins(plugins)
if 'is_public' in serializer.validated_data:
new_event.is_public = serializer.validated_data['is_public']
if 'testmode' in serializer.validated_data:
@@ -256,12 +261,17 @@ class EventViewSet(viewsets.ModelViewSet):
new_event.sales_channels = serializer.validated_data['sales_channels']
if 'has_subevents' in serializer.validated_data:
new_event.has_subevents = serializer.validated_data['has_subevents']
if 'date_admission' in serializer.validated_data:
new_event.date_admission = serializer.validated_data['date_admission']
new_event.save()
if 'timezone' in serializer.validated_data:
new_event.settings.timezone = serializer.validated_data['timezone']
else:
serializer.instance.set_defaults()
new_event.set_active_plugins(plugins if plugins is not None else settings.PRETIX_PLUGINS_DEFAULT.split(','))
new_event.save(update_fields=['plugins'])
serializer.instance.log_action(
'pretix.event.added',
user=self.request.user,
@@ -322,6 +332,7 @@ with scopes_disabled():
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
search = django_filters.rest_framework.CharFilter(method='search_qs')
class Meta:
model = SubEvent
@@ -357,6 +368,12 @@ with scopes_disabled():
def sales_channel_qs(self, queryset, name, value):
return queryset.filter(event__sales_channels__contains=value)
def search_qs(self, queryset, name, value):
return queryset.filter(
Q(name__icontains=i18ncomp(value))
| Q(location__icontains=i18ncomp(value))
)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SubEventSerializer

View File

@@ -35,7 +35,8 @@ from rest_framework.reverse import reverse
from pretix.api.serializers.exporters import (
ExporterSerializer, JobRunSerializer,
)
from pretix.base.models import CachedFile, Device, TeamAPIToken
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.models import CachedFile, Device, Event, TeamAPIToken
from pretix.base.services.export import export, multiexport
from pretix.base.signals import (
register_data_exporters, register_multievent_data_exporters,
@@ -155,7 +156,19 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
organizer=self.request.organizer
)
responses = register_multievent_data_exporters.send(self.request.organizer)
for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
raw_exporters = [
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer)
for r, response in responses
if response
]
raw_exporters = [
ex for ex in raw_exporters
if (
not isinstance(ex, OrganizerLevelExportMixin) or
perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
)
]
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex, events=events)
exporters.append(ex)
return exporters

View File

@@ -84,7 +84,9 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.items.select_related('tax_rule').prefetch_related(
'variations', 'addons', 'bundles', 'meta_values'
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
'variations__meta_values', 'variations__meta_values__property',
'require_membership_types', 'variations__require_membership_types',
).all()
def perform_create(self, serializer):
@@ -147,7 +149,11 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
return self.item.variations.all()
return self.item.variations.all().prefetch_related(
'meta_values',
'meta_values__property',
'require_membership_types'
)
def get_serializer_context(self):
ctx = super().get_serializer_context()

View File

@@ -61,12 +61,14 @@ from pretix.api.serializers.orderchange import (
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
from pretix.api.views import RichOrderingFilter
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
Invoice, InvoiceAddress, ItemMetaValue, Order, OrderFee, OrderPayment,
OrderPosition, OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule,
TeamAPIToken, generate_secret,
Invoice, InvoiceAddress, ItemMetaValue, ItemVariation,
ItemVariationMetaValue, Order, OrderFee, OrderPayment, OrderPosition,
OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule, TeamAPIToken,
generate_secret,
)
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
from pretix.base.payment import PaymentException
@@ -190,6 +192,7 @@ class OrderViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['exclude'] = self.request.query_params.getlist('exclude')
ctx['include'] = self.request.query_params.getlist('include')
return ctx
def get_queryset(self):
@@ -230,7 +233,9 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
)),
'variation',
Prefetch('variation', queryset=ItemVariation.objects.prefetch_related(
Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
)),
'answers', 'answers__options', 'answers__question',
'item__category',
'addon_to__answers', 'addon_to__answers__options', 'addon_to__answers__question',
@@ -679,28 +684,33 @@ class OrderViewSet(viewsets.ModelViewSet):
)
if order.require_approval:
email_template = request.event.settings.mail_text_order_placed_require_approval
subject_template = request.event.settings.mail_subject_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_flow:
email_template = request.event.settings.mail_text_order_free
subject_template = request.event.settings.mail_subject_order_free
log_entry = 'pretix.event.order.email.order_free'
email_attendees = request.event.settings.mail_send_order_free_attendee
email_attendees_template = request.event.settings.mail_text_order_free_attendee
subject_attendees_template = request.event.settings.mail_subject_order_free_attendee
else:
email_template = request.event.settings.mail_text_order_placed
subject_template = request.event.settings.mail_subject_order_placed
log_entry = 'pretix.event.order.email.order_placed'
email_attendees = request.event.settings.mail_send_order_placed_attendee
email_attendees_template = request.event.settings.mail_text_order_placed_attendee
subject_attendees_template = request.event.settings.mail_subject_order_placed_attendee
_order_placed_email(
request.event, order, payment.payment_provider if payment else None, email_template,
log_entry, invoice, payment, is_free=free_flow
request.event, order, email_template, subject_template,
log_entry, invoice, [payment] if payment else [], is_free=free_flow
)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry,
is_free=free_flow)
_order_placed_email_attendee(request.event, order, p, email_attendees_template, subject_attendees_template,
log_entry, is_free=free_flow)
if not free_flow and order.status == Order.STATUS_PAID and payment:
payment._send_paid_mail(invoice, None, '')
@@ -930,7 +940,7 @@ with scopes_disabled():
class OrderPositionViewSet(viewsets.ModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
filterset_class = OrderPositionFilter
@@ -992,7 +1002,11 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
'variation', 'answers', 'answers__options', 'answers__question',
Prefetch('variation', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
'answers', 'answers__options', 'answers__question',
'item__category',
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
Prefetch('meta_values', to_attr='meta_values_cached',
@@ -1604,6 +1618,17 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
user=request.user if request.user.is_authenticated else None,
auth=request.auth
)
if r.state in (OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_CANCELED, OrderRefund.REFUND_STATE_FAILED):
r.order.log_action(
f'pretix.event.order.refund.{r.state}', {
'local_id': r.local_id,
'provider': r.provider,
},
user=request.user if request.user.is_authenticated else None,
auth=request.auth
)
if mark_refunded:
try:
mark_order_refunded(

View File

@@ -515,8 +515,8 @@ class CustomerViewSet(viewsets.ModelViewSet):
raise MethodNotAllowed("Customers cannot be deleted.")
@transaction.atomic()
def perform_create(self, serializer, send_email=False):
customer = serializer.save(organizer=self.request.organizer, password=make_password(None))
def perform_create(self, serializer, send_email=False, password=None):
customer = serializer.save(organizer=self.request.organizer, password=make_password(password))
serializer.instance.log_action(
'pretix.customer.created',
user=self.request.user,
@@ -530,7 +530,7 @@ class CustomerViewSet(viewsets.ModelViewSet):
def create(self, request, *args, **kwargs):
serializer = CustomerCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
self.perform_create(serializer, send_email=serializer.validated_data.pop('send_email', False))
self.perform_create(serializer, send_email=serializer.validated_data.pop('send_email', False), password=serializer.validated_data.pop('password', None))
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@@ -23,19 +23,24 @@ import json
import logging
import time
from collections import OrderedDict
from datetime import timedelta
import requests
from celery.exceptions import MaxRetriesExceededError
from django.db import DatabaseError, connection, transaction
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import scope, scopes_disabled
from requests import RequestException
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
from pretix.api.models import (
WebHook, WebHookCall, WebHookCallRetry, WebHookEventListener,
)
from pretix.api.signals import register_webhook_events
from pretix.base.models import LogEntry
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import periodic_task
from pretix.celery_app import app
logger = logging.getLogger(__name__)
@@ -219,6 +224,10 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.expired',
_('Order expired'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.expirychanged',
_('Order expiry date changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.modified',
_('Order information changed'),
@@ -231,10 +240,30 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.changed.*',
_('Order changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.created',
_('Refund of payment created'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.created.externally',
_('External refund of payment'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.requested',
_('Refund of payment requested by customer'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.done',
_('Refund of payment completed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.canceled',
_('Refund of payment canceled'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.failed',
_('Refund of payment failed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.approved',
_('Order approved'),
@@ -275,6 +304,22 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.subevent.deleted',
pgettext_lazy('subevent', 'Event series date deleted'),
),
ParametrizedEventWebhookEvent(
'pretix.event.live.activated',
_('Shop taken live'),
),
ParametrizedEventWebhookEvent(
'pretix.event.live.deactivated',
_('Shop taken offline'),
),
ParametrizedEventWebhookEvent(
'pretix.event.testmode.activated',
_('Test-Mode of shop has been activated'),
),
ParametrizedEventWebhookEvent(
'pretix.event.testmode.deactivated',
_('Test-Mode of shop has been deactivated'),
),
)
@@ -316,59 +361,163 @@ def notify_webhooks(logentry_ids: list):
send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk))
@app.task(base=ProfiledTask, bind=True, max_retries=9, acks_late=True)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=60, acks_late=True, autoretry_for=(DatabaseError,),)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int, retry_count: int = 0):
"""
Sends out a specific webhook using adequate retry and error handling logic.
Our retry logic is a little complex since we have different constraints here:
1. We historically documented that we retry for up to three days, so we want to keep that
promise. We want to use (approximately) exponentially increasing times to keep load
manageable.
2. We want to use Celery's ``acks_late=True`` options which prevents lost tasks if a worker
crashes.
3. A limitation of Celery's redis broker implementation is that it can not properly handle
tasks that *run or wait* longer than `visibility_timeout`, which defaults to 1h, when
``acks_late`` is enabled. So any task with a *retry interval* of >1h will be restarted
many times because celery believes the worker has crashed.
4. We do like that the first few retries happen within a few seconds to work around very
intermittent connectivity issues quickly. For the longer retries with multiple hours,
we don't care if they are emitted a few minutes too late.
We therefore have a two-phase retry process:
- For all retry intervals below 5 minutes, which is the first 3 retries currently, we
schedule a new celery task directly with an increased retry_count. We do *not* use
celery's retry() call currently to make the retry process in both phases more similar,
there should not be much of a difference though (except that the initial task will be in
SUCCESS state, but we don't check that status anywhere).
- For all retry intervals of at least 5 minutes, we create a database entry. Then, the
periodic task ``schedule_webhook_retries_on_celery`` will schedule celery tasks for them
once their time has come.
"""
retry_intervals = (
5, # + 5 seconds
30, # + 30 seconds
60, # + 1 minute
300, # + 5 minutes
1200, # + 20 minutes
3600, # + 60 minutes
1440, # + 4 hours
21600, # + 6 hours
43200, # + 12 hours
43200, # + 24 hours
86400, # + 24 hours
) # added up, these are approximately 3 days, as documented
retry_celery_cutoff = 300
with scopes_disabled():
webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer):
with scope(organizer=webhook.organizer), transaction.atomic():
logentry = LogEntry.all.get(id=logentry_id)
types = get_all_webhook_events()
event_type = types.get(action_type)
if not event_type or not webhook.enabled:
return # Ignore, e.g. plugin not installed
return 'obsolete-webhook' # Ignore, e.g. plugin not installed
payload = event_type.build_payload(logentry)
if payload is None:
# Content object deleted?
return
return 'obsolete-payload'
t = time.time()
try:
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False,
timeout=30,
)
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
return 'gone'
elif resp.status_code > 299:
if retry_count >= len(retry_intervals):
return 'retry-given-up'
elif retry_intervals[retry_count] < retry_celery_cutoff:
send_webhook.apply_async(args=(logentry_id, action_type, webhook_id, retry_count + 1),
countdown=retry_intervals[retry_count])
return 'retry-via-celery'
else:
webhook.retries.update_or_create(
logentry=logentry,
defaults=dict(
retry_not_before=now() + timedelta(seconds=retry_intervals[retry_count]),
retry_count=retry_count + 1,
action_type=action_type,
),
)
return 'retry-via-db'
return 'ok'
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
if retry_count >= len(retry_intervals):
return 'retry-given-up'
elif retry_intervals[retry_count] < retry_celery_cutoff:
send_webhook.apply_async(args=(logentry_id, action_type, webhook_id, retry_count + 1))
return 'retry-via-celery'
else:
webhook.retries.update_or_create(
logentry=logentry,
defaults=dict(
retry_not_before=now() + timedelta(seconds=retry_intervals[retry_count]),
retry_count=retry_count + 1,
action_type=action_type,
),
)
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
except MaxRetriesExceededError:
pass
return 'retry-via-db'
@app.task(base=TransactionAwareTask)
def manually_retry_all_calls(webhook_id: int):
with scopes_disabled():
webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer), transaction.atomic():
for whcr in webhook.retries.select_for_update(
skip_locked=connection.features.has_select_for_update_skip_locked
):
send_webhook.apply_async(
args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count),
)
whcr.delete()
@receiver(signal=periodic_task, dispatch_uid='pretixapi_schedule_webhook_retries_on_celery')
@scopes_disabled()
def schedule_webhook_retries_on_celery(sender, **kwargs):
with transaction.atomic():
for whcr in WebHookCallRetry.objects.select_for_update(
skip_locked=connection.features.has_select_for_update_skip_locked
).filter(retry_not_before__lt=now()):
send_webhook.apply_async(
args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count),
)
whcr.delete()

View File

@@ -0,0 +1,227 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import defaultdict
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from localflavor.ar.forms import ARPostalCodeField
from localflavor.at.forms import ATZipCodeField
from localflavor.au.forms import AUPostCodeField
from localflavor.be.forms import BEPostalCodeField
from localflavor.br.forms import BRZipCodeField
from localflavor.ca.forms import CAPostalCodeField
from localflavor.ch.forms import CHZipCodeField
from localflavor.cn.forms import CNPostCodeField
from localflavor.cu.forms import CUPostalCodeField
from localflavor.cz.forms import CZPostalCodeField
from localflavor.de.forms import DEZipCodeField
from localflavor.dk.forms import DKPostalCodeField
from localflavor.ee.forms import EEZipCodeField
from localflavor.es.forms import ESPostalCodeField
from localflavor.fi.forms import FIZipCodeField
from localflavor.fr.forms import FRZipCodeField
from localflavor.gb.forms import GBPostcodeField
from localflavor.gr.forms import GRPostalCodeField
from localflavor.hr.forms import HRPostalCodeField
from localflavor.id_.forms import IDPostCodeField
from localflavor.ie.forms import EircodeField
from localflavor.il.forms import ILPostalCodeField
from localflavor.in_.forms import INZipCodeField
from localflavor.ir.forms import IRPostalCodeField
from localflavor.is_.is_postalcodes import IS_POSTALCODES
from localflavor.it.forms import ITZipCodeField
from localflavor.jp.forms import JPPostalCodeField
from localflavor.lt.forms import LTPostalCodeField
from localflavor.lv.forms import LVPostalCodeField
from localflavor.ma.forms import MAPostalCodeField
from localflavor.mt.forms import MTPostalCodeField
from localflavor.mx.forms import MXZipCodeField
from localflavor.nl.forms import NLZipCodeField
from localflavor.no.forms import NOZipCodeField
from localflavor.nz.forms import NZPostCodeField
from localflavor.pk.forms import PKPostCodeField
from localflavor.pl.forms import PLPostalCodeField
from localflavor.pt.forms import PTZipCodeField
from localflavor.ro.forms import ROPostalCodeField
from localflavor.ru.forms import RUPostalCodeField
from localflavor.se.forms import SEPostalCodeField
from localflavor.sg.forms import SGPostCodeField
from localflavor.si.si_postalcodes import SI_POSTALCODES
from localflavor.sk.forms import SKPostalCodeField
from localflavor.tr.forms import TRPostalCodeField
from localflavor.ua.forms import UAPostalCodeField
from localflavor.us.forms import USZipCodeField
from localflavor.za.forms import ZAPostCodeField
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
_validator_classes = defaultdict(list)
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED = {
# We don't presume this for countries we don't have knowledge about, there are countries in the
# world e.g. without zipcodes
'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
'GB', 'GR', 'HR', 'ID', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX',
'NL', 'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
}
def validate_address(address: dict, all_optional=False):
"""
:param address: A dictionary with at least the entries ``street``, ``zipcode``, ``city``, ``country``,
``state``
:return: The dictionary, possibly with changes
"""
if not address.get('street') and not address.get('zipcode') and not address.get('city'):
# Consider the actual address part to be empty, no further validation necessary, if the
# address should be required, it's the callers job to validate that at least one of these
# fields is filled
return address
if not address.get('country'):
raise ValidationError({'country': [_('This field is required.')]})
if str(address['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS and not address.get('state') and not all_optional:
raise ValidationError({'state': [_('This field is required.')]})
if str(address['country']) in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED and not all_optional:
for f in ('street', 'zipcode', 'city'):
if not address.get(f):
raise ValidationError({f: [_('This field is required.')]})
for klass in _validator_classes[str(address['country'])]:
validator = klass()
try:
if address.get('zipcode'):
address['zipcode'] = validator.validate_zipcode(address['zipcode'])
except ValidationError as e:
raise ValidationError({'zipcode': list(e)})
return address
def register_validator_for(country):
def inner(klass):
_validator_classes[country].append(klass)
return klass
return inner
class BaseValidator:
required_fields = []
def validate_zipcode(self, value):
return value
"""
Currently, mostly have validators that are auto-generated from django-localflavor
but custom ones can be added like this:
@register_validator_for('DE')
class DEValidator(BaseValidator):
def validate_zipcode(value):
return value
In the future, we can also add additional methods to validate that e.g. a city
is plausible for a given zip code.
"""
_zip_code_fields = {
'AR': ARPostalCodeField,
'AT': ATZipCodeField,
'AU': AUPostCodeField,
'BE': BEPostalCodeField,
'BR': BRZipCodeField,
'CA': CAPostalCodeField,
'CH': CHZipCodeField,
'CN': CNPostCodeField,
'CU': CUPostalCodeField,
'CZ': CZPostalCodeField,
'DE': DEZipCodeField,
'DK': DKPostalCodeField,
'EE': EEZipCodeField,
'ES': ESPostalCodeField,
'FI': FIZipCodeField,
'FR': FRZipCodeField,
'GB': GBPostcodeField,
'GR': GRPostalCodeField,
'HR': HRPostalCodeField,
'ID': IDPostCodeField,
'IE': EircodeField,
'IL': ILPostalCodeField,
'IN': INZipCodeField,
'IR': IRPostalCodeField,
'IT': ITZipCodeField,
'JP': JPPostalCodeField,
'LT': LTPostalCodeField,
'LV': LVPostalCodeField,
'MA': MAPostalCodeField,
'MT': MTPostalCodeField,
'MX': MXZipCodeField,
'NL': NLZipCodeField,
'NO': NOZipCodeField,
'NZ': NZPostCodeField,
'PK': PKPostCodeField,
'PL': PLPostalCodeField,
'PT': PTZipCodeField,
'RO': ROPostalCodeField,
'RU': RUPostalCodeField,
'SE': SEPostalCodeField,
'SG': SGPostCodeField,
'SK': SKPostalCodeField,
'TR': TRPostalCodeField,
'UA': UAPostalCodeField,
'US': USZipCodeField,
'ZA': ZAPostCodeField,
}
def _generate_class_from_zipcode_field(field_class):
class _GeneratedValidator(BaseValidator):
def validate_zipcode(self, value):
return field_class().clean(value)
return _GeneratedValidator
for cc, field_class in _zip_code_fields.items():
register_validator_for(cc)(_generate_class_from_zipcode_field(field_class))
@register_validator_for('IS')
class ISValidator(BaseValidator):
def validate_zipcode(self, value):
if value not in [entry[0] for entry in IS_POSTALCODES]:
raise ValidationError(_('Enter a postal code in the format XXX.'), code='invalid')
return value
@register_validator_for('SI')
class SIValidator(BaseValidator):
def validate_zipcode(self, value):
try:
if int(value) not in [entry[0] for entry in SI_POSTALCODES]:
raise ValidationError(_('Enter a postal code in the format XXXX.'), code='invalid')
except ValueError:
raise ValidationError(_('Enter a postal code in the format XXXX.'), code='invalid')
return value

View File

@@ -0,0 +1,21 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#

View File

@@ -0,0 +1,295 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import base64
import hashlib
import logging
import time
from datetime import datetime
from urllib.parse import urlencode, urljoin
import jwt
import requests
from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key
from cryptography.hazmat.primitives.serialization import (
Encoding, NoEncryption, PrivateFormat, PublicFormat,
)
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from requests import RequestException
from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__)
"""
This module contains utilities for implementing OpenID Connect for customer authentication both as a receiving party (RP)
as well as an OpenID Provider (OP).
"""
def _urljoin(base, path):
if not base.endswith("/"):
base += "/"
return urljoin(base, path)
def oidc_validate_and_complete_config(config):
for k in ("base_url", "client_id", "client_secret", "uid_field", "email_field", "scope"):
if not config.get(k):
raise ValidationError(_('Configuration option "{name}" is missing.').format(name=k))
conf_url = _urljoin(config["base_url"], ".well-known/openid-configuration")
try:
resp = requests.get(conf_url, timeout=10)
resp.raise_for_status()
provider_config = resp.json()
except RequestException as e:
raise ValidationError(_('Unable to retrieve configuration from "{url}". Error message: "{error}".').format(
url=conf_url,
error=str(e)
))
except ValueError as e:
raise ValidationError(_('Unable to retrieve configuration from "{url}". Error message: "{error}".').format(
url=conf_url,
error=str(e)
))
if not provider_config.get("authorization_endpoint"):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error="authorization_endpoint not set"
))
if not provider_config.get("userinfo_endpoint"):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error="userinfo_endpoint not set"
))
if not provider_config.get("token_endpoint"):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error="token_endpoint not set"
))
if "code" not in provider_config.get("response_types_supported", []):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error=f"provider supports response types {','.join(provider_config.get('response_types_supported', []))}, but we only support 'code'."
))
if "query" not in provider_config.get("response_modes_supported", ["query", "fragment"]):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error=f"provider supports response modes {','.join(provider_config.get('response_modes_supported', []))}, but we only support 'query'."
))
if "authorization_code" not in provider_config.get("grant_types_supported", ["authorization_code", "implicit"]):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error=f"provider supports grant types {','.join(provider_config.get('grant_types_supported', ''))}, but we only support 'authorization_code'."
))
if "openid" not in config["scope"].split(" "):
raise ValidationError(
_('You are not requesting "{scope}".').format(
scope="openid",
))
for scope in config["scope"].split(" "):
if scope not in provider_config.get("scopes_supported", []):
raise ValidationError(_('You are requesting scope "{scope}" but provider only supports these: {scopes}.').format(
scope=scope,
scopes=", ".join(provider_config.get("scopes_supported", []))
))
for k, v in config.items():
if k.endswith('_field') and v:
if v not in provider_config.get("claims_supported", []): # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format(
field=v,
fields=", ".join(provider_config.get("claims_supported", []))
))
config['provider_config'] = provider_config
return config
def oidc_authorize_url(provider, state, redirect_uri):
endpoint = provider.configuration['provider_config']['authorization_endpoint']
params = {
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
# https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint
'response_type': 'code',
'client_id': provider.configuration['client_id'],
'scope': provider.configuration['scope'],
'state': state,
'redirect_uri': redirect_uri,
}
return endpoint + '?' + urlencode(params)
def oidc_validate_authorization(provider, code, redirect_uri):
endpoint = provider.configuration['provider_config']['token_endpoint']
params = {
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
# https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
}
try:
resp = requests.post(
endpoint,
data=params,
headers={
'Accept': 'application/json',
},
auth=(provider.configuration['client_id'], provider.configuration['client_secret']),
)
resp.raise_for_status()
data = resp.json()
except RequestException:
logger.exception('Could not retrieve authorization token')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not reach login provider',
)
)
if 'access_token' not in data:
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='access token missing',
)
)
endpoint = provider.configuration['provider_config']['userinfo_endpoint']
try:
# https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
resp = requests.get(
endpoint,
headers={
'Authorization': f'Bearer {data["access_token"]}'
},
)
resp.raise_for_status()
userinfo = resp.json()
except RequestException:
logger.exception('Could not retrieve user info')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not fetch user info',
)
)
if 'email_verified' in userinfo and not userinfo['email_verified']:
# todo: how universal is this, do we need to make this configurable?
raise ValidationError(_('The email address on this account is not yet verified. Please first confirm the '
'email address in your customer account.'))
profile = {}
for k, v in provider.configuration.items():
if k.endswith('_field'):
profile[k[:-6]] = userinfo.get(v)
if not profile.get('uid'):
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not fetch user id',
)
)
if not profile.get('email'):
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not fetch user email',
)
)
return profile
def _hash_scheme(value):
# As described in https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken
digest = hashlib.sha256(value.encode()).digest()
digest_truncated = digest[:(len(digest) // 2)]
return base64.urlsafe_b64encode(digest_truncated).decode().rstrip("=")
def customer_claims(customer, scope):
scope = scope.split(' ')
claims = {
'sub': customer.identifier,
'locale': customer.locale,
}
if 'profile' in scope:
if customer.name:
claims['name'] = customer.name
if 'given_name' in customer.name_parts:
claims['given_name'] = customer.name_parts['given_name']
if 'family_name' in customer.name_parts:
claims['family_name'] = customer.name_parts['family_name']
if 'middle_name' in customer.name_parts:
claims['middle_name'] = customer.name_parts['middle_name']
if 'calling_name' in customer.name_parts:
claims['nickname'] = customer.name_parts['calling_name']
if 'email' in scope and customer.email:
claims['email'] = customer.email
claims['email_verified'] = customer.is_verified
if 'phone' in scope and customer.phone:
claims['phone_number'] = customer.phone.as_international
return claims
def _get_or_create_server_keypair(organizer):
if not organizer.settings.sso_server_signing_key_rsa256_private:
privkey = generate_private_key(key_size=4096, public_exponent=65537)
pubkey = privkey.public_key()
organizer.settings.sso_server_signing_key_rsa256_private = privkey.private_bytes(
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
).decode()
organizer.settings.sso_server_signing_key_rsa256_public = pubkey.public_bytes(
Encoding.PEM, PublicFormat.SubjectPublicKeyInfo
).decode()
return organizer.settings.sso_server_signing_key_rsa256_private, organizer.settings.sso_server_signing_key_rsa256_public
def generate_id_token(customer, client, auth_time, nonce, scope, expires: datetime, scope_claims=False, with_code=None, with_access_token=None):
payload = {
'iss': build_absolute_uri(client.organizer, 'presale:organizer.index').rstrip('/'),
'aud': client.client_id,
'exp': int(expires.timestamp()),
'iat': int(time.time()),
'auth_time': auth_time,
**customer_claims(customer, client.evaluated_scope(scope) if scope_claims else ''),
}
if nonce:
payload['nonce'] = nonce
if with_code:
payload['c_hash'] = _hash_scheme(with_code)
if with_access_token:
payload['at_hash'] = _hash_scheme(with_access_token)
privkey, pubkey = _get_or_create_server_keypair(client.organizer)
return jwt.encode(
payload,
privkey,
headers={
"kid": hashlib.sha256(pubkey.encode()).hexdigest()[:16]
},
algorithm="RS256",
)

View File

@@ -43,6 +43,7 @@ from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
)
from pretix.base.models import Event
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import (
register_html_mail_renderers, register_mail_placeholders,
@@ -299,7 +300,8 @@ def get_email_context(**kwargs):
kwargs.setdefault("position_or_address", kwargs['position'])
if 'order' in kwargs:
try:
kwargs['invoice_address'] = kwargs['order'].invoice_address
if not kwargs.get('invoice_address'):
kwargs['invoice_address'] = kwargs['order'].invoice_address
except InvoiceAddress.DoesNotExist:
kwargs['invoice_address'] = InvoiceAddress(order=kwargs['order'])
finally:
@@ -318,13 +320,18 @@ def get_email_context(**kwargs):
return ctx
def _placeholder_payment(order, payment):
if not payment:
return None
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
return str(payment.payment_provider.order_pending_mail_render(order, payment))
def _placeholder_payments(order, payments):
d = []
for payment in payments:
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
d.append(str(payment.payment_provider.order_pending_mail_render(order, payment)))
else:
d.append(str(payment.payment_provider.order_pending_mail_render(order)))
d = [line for line in d if line.strip()]
if d:
return '\n\n'.join(d)
else:
return str(payment.payment_provider.order_pending_mail_render(order))
return ''
def get_best_name(position_or_address, parts=False):
@@ -469,6 +476,19 @@ def base_placeholders(sender, **kwargs):
}
),
),
SimpleFunctionalMailTextPlaceholder(
'order_modification_deadline_date_and_time', ['order', 'event'],
lambda order, event:
date_format(order.modify_deadline.astimezone(event.timezone), 'SHORT_DATETIME_FORMAT')
if order.modify_deadline
else '',
lambda event: date_format(
event.settings.get(
'last_order_modification_date', as_type=RelativeDateWrapper
).datetime(event).astimezone(event.timezone),
'SHORT_DATETIME_FORMAT'
) if event.settings.get('last_order_modification_date') else '',
),
SimpleFunctionalMailTextPlaceholder(
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
lambda event: str(event.location or ''),
@@ -602,7 +622,7 @@ def base_placeholders(sender, **kwargs):
_('An individual text with a reason can be inserted here.'),
),
SimpleFunctionalMailTextPlaceholder(
'payment_info', ['order', 'payment'], _placeholder_payment,
'payment_info', ['order', 'payments'], _placeholder_payments,
_('The amount has been charged to your card.'),
),
SimpleFunctionalMailTextPlaceholder(

View File

@@ -51,7 +51,7 @@ from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
)
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
__ = excel_safe # just so the compatibility import above is "used" and doesn't get removed by linter
class BaseExporter:
@@ -80,7 +80,7 @@ class BaseExporter:
def verbose_name(self) -> str:
"""
A human-readable name for this exporter. This should be short but
self-explaining. Good examples include 'JSON' or 'Microsoft Excel'.
self-explaining. Good examples include 'Orders as JSON' or 'Orders as Microsoft Excel'.
"""
raise NotImplementedError() # NOQA
@@ -137,6 +137,16 @@ class BaseExporter:
raise NotImplementedError() # NOQA
class OrganizerLevelExportMixin:
@property
def organizer_required_permission(self) -> str:
"""
The permission level required to use this exporter. Only useful for organizer-level exports,
not for event-level exports.
"""
return 'can_view_orders'
class ListExporter(BaseExporter):
ProgressSetTotal = namedtuple('ProgressSetTotal', 'total')

View File

@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from .answers import * # noqa
from .customers import * # noqa
from .dekodi import * # noqa
from .events import * # noqa
from .invoices import * # noqa

View File

@@ -0,0 +1,113 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Benjamin Hättasch, Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext as _, gettext_lazy
from pretix.base.settings import PERSON_NAME_SCHEMES
from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..signals import register_multievent_data_exporters
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts')
organizer_required_permission = 'can_manage_customers'
@property
def additional_form_fields(self):
return OrderedDict(
[]
)
def iterate_list(self, form_data):
qs = self.organizer.customers.prefetch_related('provider')
headers = [
_('Customer ID'),
_('SSO provider'),
_('External identifier'),
_('E-mail'),
_('Phone number'),
_('Full name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(_('Name') + ': ' + str(label))
headers += [
_('Account active'),
_('Verified email address'),
_('Last login'),
_('Registration date'),
_('Language'),
_('Notes'),
]
yield headers
tz = get_current_timezone()
for obj in qs:
row = [
obj.identifier,
obj.provider.name if obj.provider else None,
obj.external_identifier,
obj.email or '',
obj.phone or '',
obj.name,
]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(obj.name_parts.get(k, ''))
row += [
_('Yes') if obj.is_active else _('No'),
_('Yes') if obj.is_verified else _('No'),
obj.last_login.astimezone(tz).date().strftime('%Y-%m-%d') if obj.last_login else '',
obj.date_joined.astimezone(tz).date().strftime('%Y-%m-%d') if obj.date_joined else '',
obj.get_locale_display(),
obj.notes or '',
]
yield row
def get_filename(self):
return '{}_customers'.format(self.organizer.slug)
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_customerlist")
def register_multievent_i_customerlist_exporter(sender, **kwargs):
return CustomerListExporter

View File

@@ -29,7 +29,7 @@ from openpyxl.utils import get_column_letter
from ...helpers.safe_openpyxl import SafeCell
from ..channels import get_all_sales_channels
from ..exporter import ListExporter
from ..models import ItemMetaValue
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
from ..signals import register_data_exporters
@@ -106,18 +106,27 @@ class ItemDataExporter(ListExporter):
yield row
for i in self.event.items.prefetch_related(
'variations',
Prefetch(
'meta_values',
ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
)
),
Prefetch(
'variations',
queryset=ItemVariation.objects.prefetch_related(
Prefetch(
'meta_values',
ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
),
),
).select_related('category', 'tax_rule'):
m = i.meta_data
vars = list(i.variations.all())
if vars:
for v in vars:
m = v.meta_data
row = [
i.pk,
v.pk,
@@ -160,6 +169,7 @@ class ItemDataExporter(ListExporter):
yield row
else:
m = i.meta_data
row = [
i.pk,
"",

View File

@@ -36,9 +36,11 @@ import json
from decimal import Decimal
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Prefetch
from django.dispatch import receiver
from ..exporter import BaseExporter
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
from ..signals import register_data_exporters
@@ -106,9 +108,26 @@ class JSONExporter(BaseExporter):
'available_from': variation.available_from,
'available_until': variation.available_until,
'hide_without_voucher': variation.hide_without_voucher,
'meta_data': variation.meta_data,
} for variation in item.variations.all()
]
} for item in self.event.items.select_related('tax_rule').prefetch_related('variations')
} for item in self.event.items.select_related('tax_rule').prefetch_related(
Prefetch(
'meta_values',
ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
Prefetch(
'variations',
queryset=ItemVariation.objects.prefetch_related(
Prefetch(
'meta_values',
ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
),
),
)
],
'questions': [
{

View File

@@ -60,7 +60,9 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable
from ..exporter import ListExporter, MultiSheetListExporter
from ..exporter import (
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
)
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
@@ -301,6 +303,8 @@ class OrderListExporter(MultiSheetListExporter):
for id, vn in payment_methods:
headers.append(_('Paid by {method}').format(method=vn))
# get meta_data labels from first cached event
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
yield headers
full_fee_sum_cache = {
@@ -414,6 +418,7 @@ class OrderListExporter(MultiSheetListExporter):
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
refund_sum_cache.get((order.id, id), Decimal('0.00'))
)
row += self.event_object_cache[order.event_id].meta_data.values()
yield row
def iterate_fees(self, form_data: dict):
@@ -463,6 +468,9 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('External customer ID'))
headers.append(_('Payment providers'))
# get meta_data labels from first cached event
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
yield headers
yield self.ProgressSetTotal(total=qs.count())
@@ -510,6 +518,7 @@ class OrderListExporter(MultiSheetListExporter):
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
]))
row += self.event_object_cache[order.event_id].meta_data.values()
yield row
def iterate_positions(self, form_data: dict):
@@ -531,6 +540,7 @@ class OrderListExporter(MultiSheetListExporter):
'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
'voucher', 'tax_rule'
).prefetch_related(
'subevent', 'subevent__meta_values',
'answers', 'answers__question', 'answers__options'
)
if form_data['paid_only']:
@@ -622,6 +632,10 @@ class OrderListExporter(MultiSheetListExporter):
_('Payment providers'),
]
# get meta_data labels from first cached event
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
if has_subevents:
headers += meta_data_labels
yield headers
all_ids = list(base_qs.order_by('order__datetime', 'positionid').values_list('pk', flat=True))
@@ -745,6 +759,12 @@ class OrderListExporter(MultiSheetListExporter):
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
]))
if has_subevents:
if op.subevent:
row += op.subevent.meta_data.values()
else:
row += [''] * len(meta_data_labels)
yield row
def get_filename(self):
@@ -884,76 +904,75 @@ class QuotaListExporter(ListExporter):
return '{}_quotas'.format(self.event.slug)
def generate_GiftCardTransactionListExporter(organizer): # hackhack
class GiftcardTransactionListExporter(ListExporter):
identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions')
class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions')
organizer_required_permission = 'can_manage_gift_cards'
@property
def additional_form_fields(self):
d = [
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
@property
def additional_form_fields(self):
d = [
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
]
d = OrderedDict(d)
return d
def iterate_list(self, form_data):
qs = GiftCardTransaction.objects.filter(
card__issuer=self.organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
)
headers = [
_('Gift card code'),
_('Test mode'),
_('Date'),
_('Amount'),
_('Currency'),
_('Order'),
]
yield headers
for obj in qs:
row = [
obj.card.secret,
_('TEST MODE') if obj.card.testmode else '',
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
obj.value,
obj.card.currency,
obj.order.full_code if obj.order else None,
]
d = OrderedDict(d)
return d
yield row
def iterate_list(self, form_data):
qs = GiftCardTransaction.objects.filter(
card__issuer=organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
)
headers = [
_('Gift card code'),
_('Test mode'),
_('Date'),
_('Amount'),
_('Currency'),
_('Order'),
]
yield headers
for obj in qs:
row = [
obj.card.secret,
_('TEST MODE') if obj.card.testmode else '',
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
obj.value,
obj.card.currency,
obj.order.full_code if obj.order else None,
]
yield row
def get_filename(self):
return '{}_giftcardtransactions'.format(organizer.slug)
return GiftcardTransactionListExporter
def get_filename(self):
return '{}_giftcardtransactions'.format(self.organizer.slug)
class GiftcardRedemptionListExporter(ListExporter):
@@ -1000,114 +1019,112 @@ class GiftcardRedemptionListExporter(ListExporter):
return '{}_giftcardredemptions'.format(self.event.slug)
def generate_GiftCardListExporter(organizer): # hackhack
class GiftcardListExporter(ListExporter):
identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards')
class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards')
organizer_required_permission = 'can_manage_gift_cards'
@property
def additional_form_fields(self):
return OrderedDict(
[
('date', forms.DateTimeField(
label=_('Show value at'),
initial=now(),
)),
('testmode', forms.ChoiceField(
label=_('Test mode'),
choices=(
('', _('All')),
('yes', _('Test mode')),
('no', _('Live')),
),
initial='no',
required=False
)),
('state', forms.ChoiceField(
label=_('Status'),
choices=(
('', _('All')),
('empty', _('Empty')),
('valid_value', _('Valid and with value')),
('expired_value', _('Expired and with value')),
('expired', _('Expired')),
),
initial='valid_value',
required=False
))
]
)
def iterate_list(self, form_data):
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'),
datetime__lte=form_data['date']
).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = organizer.issued_gift_cards.filter(
issuance__lte=form_data['date']
).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related(
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
)
if form_data.get('testmode') == 'yes':
qs = qs.filter(testmode=True)
elif form_data.get('testmode') == 'no':
qs = qs.filter(testmode=False)
if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value':
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
elif form_data.get('state') == 'expired_value':
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
elif form_data.get('state') == 'expired':
qs = qs.filter(expires__lt=form_data['date'])
headers = [
_('Gift card code'),
_('Test mode card'),
_('Creation date'),
_('Expiry date'),
_('Special terms and conditions'),
_('Currency'),
_('Current value'),
_('Created in order'),
_('Last invoice number of order'),
_('Last invoice date of order'),
@property
def additional_form_fields(self):
return OrderedDict(
[
('date', forms.DateTimeField(
label=_('Show value at'),
initial=now(),
)),
('testmode', forms.ChoiceField(
label=_('Test mode'),
choices=(
('', _('All')),
('yes', _('Test mode')),
('no', _('Live')),
),
initial='no',
required=False
)),
('state', forms.ChoiceField(
label=_('Status'),
choices=(
('', _('All')),
('empty', _('Empty')),
('valid_value', _('Valid and with value')),
('expired_value', _('Expired and with value')),
('expired', _('Expired')),
),
initial='valid_value',
required=False
))
]
yield headers
)
tz = get_current_timezone()
for obj in qs:
o = None
i = None
trans = list(obj.transactions.all())
if trans:
o = trans[0].order
if o:
invs = list(o.invoices.all())
if invs:
i = invs[-1]
row = [
obj.secret,
_('Yes') if obj.testmode else _('No'),
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
obj.conditions or '',
obj.currency,
obj.cached_value,
o.full_code if o else '',
i.number if i else '',
i.date.strftime('%Y-%m-%d') if i else '',
]
yield row
def iterate_list(self, form_data):
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'),
datetime__lte=form_data['date']
).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = self.organizer.issued_gift_cards.filter(
issuance__lte=form_data['date']
).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related(
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
)
def get_filename(self):
return '{}_giftcards'.format(organizer.slug)
if form_data.get('testmode') == 'yes':
qs = qs.filter(testmode=True)
elif form_data.get('testmode') == 'no':
qs = qs.filter(testmode=False)
return GiftcardListExporter
if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value':
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
elif form_data.get('state') == 'expired_value':
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
elif form_data.get('state') == 'expired':
qs = qs.filter(expires__lt=form_data['date'])
headers = [
_('Gift card code'),
_('Test mode card'),
_('Creation date'),
_('Expiry date'),
_('Special terms and conditions'),
_('Currency'),
_('Current value'),
_('Created in order'),
_('Last invoice number of order'),
_('Last invoice date of order'),
]
yield headers
tz = get_current_timezone()
for obj in qs:
o = None
i = None
trans = list(obj.transactions.all())
if trans:
o = trans[0].order
if o:
invs = list(o.invoices.all())
if invs:
i = invs[-1]
row = [
obj.secret,
_('Yes') if obj.testmode else _('No'),
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
obj.conditions or '',
obj.currency,
obj.cached_value,
o.full_code if o else '',
i.number if i else '',
i.date.strftime('%Y-%m-%d') if i else '',
]
yield row
def get_filename(self):
return '{}_giftcards'.format(self.organizer.slug)
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
@@ -1147,9 +1164,9 @@ def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
return generate_GiftCardListExporter(sender)
return GiftcardListExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardtransactionlist")
def register_multievent_i_giftcardtransactionlist_exporter(sender, **kwargs):
return generate_GiftCardTransactionListExporter(sender)
return GiftcardTransactionListExporter

View File

@@ -51,6 +51,7 @@ from django.core.validators import (
)
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -134,6 +135,10 @@ class NamePartsWidget(forms.MultiWidget):
data.append(value.get(fname, ""))
if '_legacy' in value and not data[-1]:
data[-1] = value.get('_legacy', '')
elif not any(d for d in data) and '_scheme' in value:
scheme = PERSON_NAME_SCHEMES[value['_scheme']]
data[-1] = scheme['concatenation'](value).strip()
return data
def render(self, name: str, value, attrs=None, renderer=None) -> str:
@@ -429,7 +434,7 @@ class PortraitImageWidget(UploadedFileWidget):
def value_from_datadict(self, data, files, name):
d = super().value_from_datadict(data, files, name)
if d is not None and d is not False:
if d is not None and d is not False and d is not FILE_INPUT_CONTRADICTION:
d._cropdata = json.loads(data.get(name + '_cropdata', '{}') or '{}')
return d
@@ -914,6 +919,7 @@ class BaseQuestionsForm(forms.Form):
class BaseInvoiceAddressForm(forms.ModelForm):
vat_warning = False
address_validation = False
class Meta:
model = InvoiceAddress
@@ -1049,6 +1055,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
data = self.cleaned_data
if not data.get('is_business'):
data['company'] = ''
@@ -1064,9 +1073,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False
if data.get('city') and data.get('country') and str(data['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not data.get('state'):
self.add_error('state', _('This field is required.'))
if self.address_validation:
self.cleaned_data = data = validate_address(data, self.all_optional)
self.instance.name_parts = data.get('name_parts')

View File

@@ -23,6 +23,7 @@ import logging
from collections import defaultdict
from decimal import Decimal
from io import BytesIO
from itertools import groupby
from typing import Tuple
import bleach
@@ -241,6 +242,12 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
buffer.seek(0)
return 'invoice.pdf', 'application/pdf', buffer.read()
def _clean_text(self, text, tags=None):
return bleach.clean(
text,
tags=tags or []
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
identifier = 'classic'
@@ -265,7 +272,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas):
p = Paragraph(bleach.clean(self.invoice.address_invoice_to, tags=[]).strip().replace('\n', '<br />\n'),
p = Paragraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
@@ -278,7 +285,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_invoice_from(self, canvas):
p = Paragraph(
bleach.clean(self.invoice.full_invoice_from, tags=[]).strip().replace('\n', '<br />\n'),
self._clean_text(self.invoice.full_invoice_from),
style=self.stylesheet['InvoiceFrom']
)
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
@@ -473,8 +480,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.custom_field:
story.append(Paragraph(
'{}: {}'.format(
bleach.clean(str(self.invoice.event.settings.invoice_address_custom_field), tags=[]).strip().replace('\n', '<br />\n'),
bleach.clean(self.invoice.custom_field, tags=[]).strip().replace('\n', '<br />\n'),
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
self._clean_text(self.invoice.custom_field),
),
self.stylesheet['Normal']
))
@@ -482,7 +489,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.internal_reference:
story.append(Paragraph(
pgettext('invoice', 'Customer reference: {reference}').format(
reference=bleach.clean(self.invoice.internal_reference, tags=[]).strip().replace('\n', '<br />\n'),
reference=self._clean_text(self.invoice.internal_reference),
),
self.stylesheet['Normal']
))
@@ -490,20 +497,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.invoice_to_vat_id:
story.append(Paragraph(
pgettext('invoice', 'Customer VAT ID') + ': ' +
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
self._clean_text(self.invoice.invoice_to_vat_id),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(Paragraph(
pgettext('invoice', 'Beneficiary') + ':<br />' +
bleach.clean(self.invoice.invoice_to_beneficiary, tags=[]).replace("\n", "<br />\n"),
self._clean_text(self.invoice.invoice_to_beneficiary),
self.stylesheet['Normal']
))
if self.invoice.introductory_text:
story.append(Paragraph(
self.invoice.introductory_text,
self._clean_text(self.invoice.introductory_text, tags=['br']),
self.stylesheet['Normal']
))
story.append(Spacer(1, 10 * mm))
@@ -533,6 +540,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
tstyledata = [
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
('FONTNAME', (0, 0), (-1, 0), self.font_bold),
('FONTNAME', (0, -1), (-1, -1), self.font_bold),
('LEFTPADDING', (0, 0), (0, -1), 0),
@@ -553,31 +561,47 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
pgettext('invoice', 'Amount'),
)]
def _group_key(line):
return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent_id,
line.event_date_from, line.event_date_to)
total = Decimal('0.00')
for line in self.invoice.lines.all():
for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in groupby(self.invoice.lines.all(), key=_group_key):
lines = list(lines)
if has_taxes:
if len(lines) > 1:
single_price_line = pgettext('invoice', 'Single price: {net_price} net / {gross_price} gross').format(
net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
tdata.append((
Paragraph(
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
"1",
localize(line.tax_rate) + " %",
money_filter(line.net_value, self.invoice.event.currency),
money_filter(line.gross_value, self.invoice.event.currency),
str(len(lines)),
localize(tax_rate) + " %",
money_filter(net_value * len(lines), self.invoice.event.currency),
money_filter(gross_value * len(lines), self.invoice.event.currency),
))
else:
if len(lines) > 1:
single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
tdata.append((
Paragraph(
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
"1",
money_filter(line.gross_value, self.invoice.event.currency),
str(len(lines)),
money_filter(gross_value * len(lines), self.invoice.event.currency),
))
taxvalue_map[line.tax_rate, line.tax_name] += line.tax_value
grossvalue_map[line.tax_rate, line.tax_name] += line.gross_value
total += line.gross_value
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
total += gross_value * len(lines)
if has_taxes:
tdata.append([
@@ -639,7 +663,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.additional_text:
story.append(Paragraph(
self.invoice.additional_text,
self._clean_text(self.invoice.additional_text, tags=['br']),
self.stylesheet['Normal']
))
story.append(Spacer(1, 5 * mm))
@@ -776,7 +800,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
if not self.invoice.invoice_from:
return
c = [
bleach.clean(l, tags=[]).strip().replace('\n', '<br />\n')
self._clean_text(l)
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = Paragraph(' · '.join(c), style=self.stylesheet['Sender'])

View File

@@ -103,6 +103,8 @@ class Command(BaseCommand):
with language(locale), override(timezone):
for receiver, response in signal_result:
if not response:
return None
ex = response(e, o, report_status)
if ex.identifier == options['export_provider']:
params = json.loads(options.get('parameters') or '{}')

View File

@@ -79,9 +79,9 @@ class Command(BaseCommand):
if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception
capture_exception(err)
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
self.stdout.write(self.style.ERROR(f'ERROR {name}: {str(err)}\n'))
else:
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
self.stdout.write(self.style.ERROR(f'ERROR {name}: {str(err)}\n'))
traceback.print_exc()
else:
if options.get('verbosity') > 1:

View File

@@ -0,0 +1,38 @@
# Generated by Django 3.2.12 on 2022-07-06 09:13
import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0218_checkinlist_addon_match'),
]
operations = [
migrations.CreateModel(
name='CustomerSSOProvider',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', i18nfield.fields.I18nCharField(max_length=200)),
('is_active', models.BooleanField(default=True)),
('button_label', i18nfield.fields.I18nCharField(max_length=200)),
('method', models.CharField(max_length=190)),
('configuration', models.JSONField()),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_providers', to='pretixbase.organizer')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AddField(
model_name='customer',
name='provider',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='pretixbase.customerssoprovider'),
),
]

View File

@@ -0,0 +1,68 @@
# Generated by Django 3.2.12 on 2022-08-11 10:02
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
import pretix.base.models.customers
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0219_auto_20220706_0913'),
]
operations = [
migrations.CreateModel(
name='CustomerSSOClient',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('is_active', models.BooleanField(default=True)),
('client_id', models.CharField(db_index=True, default=pretix.base.models.customers.generate_client_id, max_length=100, unique=True)),
('client_secret', models.CharField(max_length=255)),
('client_type', models.CharField(default='confidential', max_length=32)),
('authorization_grant_type', models.CharField(default='authorization-code', max_length=32)),
('redirect_uris', models.TextField()),
('allowed_scopes', pretix.base.models.fields.MultiStringField(default=['openid', 'profile', 'email', 'phone'])),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_clients', to='pretixbase.organizer')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AlterField(
model_name='customer',
name='provider',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='customers', to='pretixbase.customerssoprovider'),
),
migrations.CreateModel(
name='CustomerSSOGrant',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('code', models.CharField(max_length=255, unique=True)),
('nonce', models.CharField(max_length=255, null=True)),
('auth_time', models.IntegerField()),
('expires', models.DateTimeField()),
('redirect_uri', models.TextField()),
('scope', models.TextField()),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to='pretixbase.customerssoclient')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_grants', to='pretixbase.customer')),
],
),
migrations.CreateModel(
name='CustomerSSOAccessToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('from_code', models.CharField(max_length=255, null=True)),
('token', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField()),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='pretixbase.customerssoclient')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_access_tokens', to='pretixbase.customer')),
],
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.2.4 on 2021-12-01 11:55
from django.db import migrations
from django.db.models import Count
def change_unique_identifiers(apps, schema_editor):
# We cannot really know if a position was bundled or an add-on, but we can at least guess
Question = apps.get_model("pretixbase", "Question")
for r in Question.objects.values('event', 'identifier').order_by().annotate(c=Count('*')).filter(c__gt=1):
qs = Question.objects.filter(identifier=r['identifier'], event_id=r['event'])
for i, q in enumerate(qs[1:]):
q.identifier += f'_{i + 2}'
q.save(update_fields=['identifier'])
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0220_auto_20220811_1002'),
]
operations = [
migrations.RunPython(
change_unique_identifiers,
migrations.RunPython.noop,
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-12-01 12:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0221_clean_nonunique_question_identifiers'),
]
operations = [
migrations.AlterUniqueTogether(
name='question',
unique_together={('event', 'identifier')},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-10-12 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0222_alter_question_unique_together'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='min_usages',
field=models.PositiveIntegerField(default=1),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2022-11-14 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0223_voucher_min_usages'),
]
operations = [
migrations.AddField(
model_name='eventmetaproperty',
name='filter_allowed',
field=models.BooleanField(default=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.16 on 2022-11-17 15:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0224_eventmetaproperty_filter_allowed'),
]
operations = [
migrations.AddField(
model_name='orderpayment',
name='process_initiated',
field=models.BooleanField(null=True),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 3.2.16 on 2022-12-09 10:06
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0225_orderpayment_process_initiated'),
]
operations = [
migrations.CreateModel(
name='ItemVariationMetaValue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('value', models.TextField()),
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variation_values', to='pretixbase.itemmetaproperty')),
('variation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_values', to='pretixbase.itemvariation')),
],
options={
'unique_together': {('variation', 'property')},
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
]

View File

@@ -34,8 +34,8 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemVariation, Question, QuestionOption, Quota, SubEventItem,
SubEventItemVariation, itempicture_upload_to,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SubEventItem, SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .memberships import Membership, MembershipType

View File

@@ -24,28 +24,61 @@ from django.conf import settings
from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password,
)
from django.core.validators import RegexValidator
from django.core.validators import RegexValidator, URLValidator
from django.db import models
from django.db.models import F, Q
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.organizer import Organizer
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.helpers.countries import FastCountryField
class CustomerSSOProvider(LoggedModel):
METHOD_OIDC = 'oidc'
METHODS = (
(METHOD_OIDC, 'OpenID Connect'),
)
id = models.BigAutoField(primary_key=True)
organizer = models.ForeignKey(Organizer, related_name='sso_providers', on_delete=models.CASCADE)
name = I18nCharField(
max_length=200,
verbose_name=_("Provider name"),
)
is_active = models.BooleanField(default=True, verbose_name=_('Active'))
button_label = I18nCharField(
max_length=200,
verbose_name=_("Login button label"),
)
method = models.CharField(
max_length=190,
verbose_name=_("Single-sign-on method"),
null=False, blank=False,
choices=METHODS,
)
configuration = models.JSONField()
def allow_delete(self):
return not self.customers.exists()
class Customer(LoggedModel):
"""
Represents a registered customer of an organizer.
"""
id = models.BigAutoField(primary_key=True)
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
provider = models.ForeignKey(CustomerSSOProvider, related_name='customers', on_delete=models.PROTECT, null=True, blank=True)
identifier = models.CharField(
verbose_name=_('Customer ID'),
max_length=190,
db_index=True,
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
@@ -229,7 +262,7 @@ class Customer(LoggedModel):
) + '?id=' + self.identifier + '&token=' + token
mail(
self.email,
_('Activate your account at {organizer}').format(organizer=self.organizer.name),
self.organizer.settings.mail_subject_customer_registration,
self.organizer.settings.mail_text_customer_registration,
ctx,
locale=self.locale,
@@ -317,3 +350,134 @@ class AttendeeProfile(models.Model):
parts.append(f'{a["field_label"]}: {val}')
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
def generate_client_id():
return get_random_string(40)
def generate_client_secret():
return get_random_string(40)
class CustomerSSOClient(LoggedModel):
CLIENT_CONFIDENTIAL = "confidential"
CLIENT_PUBLIC = "public"
CLIENT_TYPES = (
(CLIENT_CONFIDENTIAL, pgettext_lazy("openidconnect", "Confidential")),
(CLIENT_PUBLIC, pgettext_lazy("openidconnect", "Public")),
)
GRANT_AUTHORIZATION_CODE = "authorization-code"
GRANT_IMPLICIT = "implicit"
GRANT_TYPES = (
(GRANT_AUTHORIZATION_CODE, pgettext_lazy("openidconnect", "Authorization code")),
(GRANT_IMPLICIT, pgettext_lazy("openidconnect", "Implicit")),
)
SCOPE_CHOICES = (
('openid', _('OpenID Connect access (required)')),
('profile', _('Profile data (name, addresses)')),
('email', _('E-mail address')),
('phone', _('Phone number')),
)
id = models.BigAutoField(primary_key=True)
organizer = models.ForeignKey(Organizer, related_name='sso_clients', on_delete=models.CASCADE)
name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False)
is_active = models.BooleanField(default=True, verbose_name=_('Active'))
client_id = models.CharField(
verbose_name=_("Client ID"),
max_length=100, unique=True, default=generate_client_id, db_index=True
)
client_secret = models.CharField(
max_length=255, blank=False,
)
client_type = models.CharField(
max_length=32, choices=CLIENT_TYPES, verbose_name=_("Client type"), default=CLIENT_CONFIDENTIAL,
)
authorization_grant_type = models.CharField(
max_length=32, choices=GRANT_TYPES, verbose_name=_("Grant type"), default=GRANT_AUTHORIZATION_CODE,
)
redirect_uris = models.TextField(
blank=False,
verbose_name=_("Redirection URIs"),
help_text=_("Allowed URIs list, space separated")
)
allowed_scopes = MultiStringField(
default=['openid', 'profile', 'email', 'phone'],
delimiter=" ",
blank=True,
verbose_name=_('Allowed access scopes'),
help_text=_('Separate multiple values with spaces'),
)
def is_usable(self):
return self.is_active
def allow_redirect_uri(self, redirect_uri):
return self.redirect_uris and any(r.strip() == redirect_uri for r in self.redirect_uris.split(' '))
def allow_delete(self):
return True
def evaluated_scope(self, scope):
scope = set(scope.split(' '))
allowed_scopes = set(self.allowed_scopes)
return ' '.join(scope & allowed_scopes)
def clean(self):
redirect_uris = self.redirect_uris.strip().split()
if redirect_uris:
validator = URLValidator()
for uri in redirect_uris:
validator(uri)
def set_client_secret(self):
secret = get_random_string(64)
self.client_secret = make_password(secret)
return secret
def check_client_secret(self, raw_secret):
"""
Return a boolean of whether the ra_secret was correct. Handles
hashing formats behind the scenes.
"""
def setter(raw_secret):
self.client_secret = make_password(raw_secret)
self.save(update_fields=["client_secret"])
return check_password(raw_secret, self.client_secret, setter)
class CustomerSSOGrant(models.Model):
id = models.BigAutoField(primary_key=True)
client = models.ForeignKey(
CustomerSSOClient, on_delete=models.CASCADE, related_name="grants"
)
customer = models.ForeignKey(
Customer, on_delete=models.CASCADE, related_name="sso_grants"
)
code = models.CharField(max_length=255, unique=True)
nonce = models.CharField(max_length=255, null=True, blank=True)
auth_time = models.IntegerField()
expires = models.DateTimeField()
redirect_uri = models.TextField()
scope = models.TextField(blank=True)
class CustomerSSOAccessToken(models.Model):
id = models.BigAutoField(primary_key=True)
client = models.ForeignKey(
CustomerSSOClient, on_delete=models.CASCADE, related_name="access_tokens"
)
customer = models.ForeignKey(
Customer, on_delete=models.CASCADE, related_name="sso_access_tokens"
)
from_code = models.CharField(max_length=255, null=True, blank=True)
token = models.CharField(max_length=255, unique=True)
expires = models.DateTimeField()
scope = models.TextField(blank=True)

View File

@@ -28,6 +28,7 @@ from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
@@ -198,6 +199,14 @@ class Discount(LoggedModel):
'subevent_mode': self.subevent_mode,
})
def is_available_by_time(self, now_dt=None) -> bool:
now_dt = now_dt or now()
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
return False
return True
def _apply_min_value(self, positions, idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value:
return
@@ -327,7 +336,7 @@ class Discount(LoggedModel):
candidates = []
cardinality = None
for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll not in current_group]
l = [ll for ll in l if ll in initial_candidates and ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx][1] for idx in current_group}:

View File

@@ -69,6 +69,7 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.validators import EventSlugBanlistValidator
from pretix.helpers.database import GroupConcat
from pretix.helpers.daterange import daterange
from pretix.helpers.hierarkey import clean_filename
from pretix.helpers.json import safe_string
from pretix.helpers.thumb import get_thumbnail
@@ -122,6 +123,16 @@ class EventMixin:
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
)
def get_weekday_from_display(self, tz=None, short=False) -> str:
"""
Returns a formatted string containing the weekday of the start date of the event with respect
to the current locale.
"""
tz = tz or self.timezone
return _date(
self.date_from.astimezone(tz), ("D" if short else "l")
)
def get_time_from_display(self, tz=None) -> str:
"""
Returns a formatted string containing the start time of the event, ignoring
@@ -146,6 +157,18 @@ class EventMixin:
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
)
def get_weekday_to_display(self, tz=None, short=False) -> str:
"""
Returns a formatted string containing the weekday of the end date of the event with respect
to the current locale.
"""
tz = tz or self.timezone
if not self.settings.show_date_to or not self.date_to:
return ""
return _date(
self.date_to.astimezone(tz), ("D" if short else "l")
)
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False) -> str:
"""
Returns a formatted string containing the start date and the end date
@@ -567,6 +590,7 @@ class Event(EventMixin, LoggedModel):
self.settings.event_list_type = 'calendar'
self.settings.invoice_email_attachment = True
self.settings.name_scheme = 'given_family'
self.settings.payment_banktransfer_invoice_immediately = True
@property
def social_image(self):
@@ -704,7 +728,7 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
Question, Quota,
ItemVariationMetaValue, Question, Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -780,12 +804,18 @@ class Event(EventMixin, LoggedModel):
v.item = i
v.save(force_insert=True)
for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'):
for imv in ItemMetaValue.objects.filter(item__event=other):
imv.pk = None
imv.property = item_meta_properties_map[imv.property.pk]
imv.property = item_meta_properties_map[imv.property_id]
imv.item = item_map[imv.item.pk]
imv.save(force_insert=True)
for imv in ItemVariationMetaValue.objects.filter(variation__item__event=other):
imv.pk = None
imv.property = item_meta_properties_map[imv.property_id]
imv.variation = variation_map[imv.variation_id]
imv.save(force_insert=True)
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
ia.pk = None
ia.base_item = item_map[ia.base_item.pk]
@@ -917,11 +947,13 @@ class Event(EventMixin, LoggedModel):
s.object = self
s.pk = None
if s.value.startswith('file://'):
fi = default_storage.open(s.value[7:], 'rb')
fi = default_storage.open(s.value[len('file://'):], 'rb')
nonce = get_random_string(length=8)
fname_base = clean_filename(os.path.basename(s.value))
# TODO: make sure pub is always correct
fname = 'pub/%s/%s/%s.%s.%s' % (
self.organizer.slug, self.slug, s.key, nonce, s.value.split('.')[-1]
self.organizer.slug, self.slug, fname_base, nonce, s.value.split('.')[-1]
)
newname = default_storage.save(fname, fi)
s.value = 'file://' + newname
@@ -1554,6 +1586,11 @@ class EventMetaProperty(LoggedModel):
verbose_name=_("Valid values"),
help_text=_("If you keep this empty, any value is allowed. Otherwise, enter one possible value per line.")
)
filter_allowed = models.BooleanField(
default=True, verbose_name=_("Can be used for filtering"),
help_text=_("This field will be shown to filter events or reports in the backend, and it can also be used "
"for hidden filter parameters in the frontend (e.g. using the widget).")
)
def full_clean(self, exclude=None, validate_unique=True):
super().full_clean(exclude, validate_unique)

View File

@@ -33,7 +33,8 @@ class MultiStringField(TextField):
'delimiter_found': _('No value can contain the delimiter character.')
}
def __init__(self, verbose_name=None, name=None, **kwargs):
def __init__(self, verbose_name=None, name=None, delimiter=DELIMITER, **kwargs):
self.delimiter = delimiter
super().__init__(verbose_name, name, **kwargs)
def deconstruct(self):
@@ -44,13 +45,13 @@ class MultiStringField(TextField):
if isinstance(value, (list, tuple)):
return value
elif value:
return [v for v in value.split(DELIMITER) if v]
return [v for v in value.split(self.delimiter) if v]
else:
return []
def get_prep_value(self, value):
if isinstance(value, (list, tuple)):
return DELIMITER + DELIMITER.join(value) + DELIMITER
return self.delimiter + self.delimiter.join(value) + self.delimiter
elif value is None:
if self.null:
return None
@@ -63,14 +64,14 @@ class MultiStringField(TextField):
def from_db_value(self, value, expression, connection):
if value:
return [v for v in value.split(DELIMITER) if v]
return [v for v in value.split(self.delimiter) if v]
else:
return []
def validate(self, value, model_instance):
super().validate(value, model_instance)
for l in value:
if DELIMITER in l:
if self.delimiter in l:
raise exceptions.ValidationError(
self.error_messages['delimiter_found'],
code='delimiter_found',
@@ -78,9 +79,9 @@ class MultiStringField(TextField):
def get_lookup(self, lookup_name):
if lookup_name == 'contains':
return MultiStringContains
return make_multistring_contains_lookup(self.delimiter)
elif lookup_name == 'icontains':
return MultiStringIContains
return make_multistring_icontains_lookup(self.delimiter)
elif lookup_name == 'isnull':
return builtin_lookups.IsNull
raise NotImplementedError(
@@ -88,18 +89,22 @@ class MultiStringField(TextField):
)
class MultiStringContains(builtin_lookups.Contains):
def process_rhs(self, qn, connection):
sql, params = super().process_rhs(qn, connection)
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
return sql, params
def make_multistring_contains_lookup(delimiter):
class Cls(builtin_lookups.Contains):
def process_rhs(self, qn, connection):
sql, params = super().process_rhs(qn, connection)
params[0] = "%" + delimiter + params[0][1:-1] + delimiter + "%"
return sql, params
return Cls
class MultiStringIContains(builtin_lookups.IContains):
def process_rhs(self, qn, connection):
sql, params = super().process_rhs(qn, connection)
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
return sql, params
def make_multistring_icontains_lookup(delimiter):
class Cls(builtin_lookups.IContains):
def process_rhs(self, qn, connection):
sql, params = super().process_rhs(qn, connection)
params[0] = "%" + delimiter + params[0][1:-1] + delimiter + "%"
return sql, params
return Cls
class MultiStringSerializer(serializers.Field):

View File

@@ -581,18 +581,15 @@ class Item(LoggedModel):
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
price = price if price is not None else self.default_price
if not self.tax_rule:
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
override_tax_rate=override_tax_rate, currency=currency or self.event.currency)
bundled_sum = Decimal('0.00')
bundled_sum_net = Decimal('0.00')
bundled_sum_tax = Decimal('0.00')
if include_bundled:
for b in self.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
if b.bundled_variation:
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
bprice = b.bundled_variation.tax(b.designated_price * b.count,
base_price_is='gross',
invoice_address=invoice_address,
currency=currency)
else:
@@ -600,13 +597,23 @@ class Item(LoggedModel):
invoice_address=invoice_address,
base_price_is='gross',
currency=currency)
compare_price = self.tax_rule.tax(b.designated_price * b.count,
override_tax_rate=override_tax_rate,
invoice_address=invoice_address,
currency=currency)
t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!"
bundled_sum += bprice.gross
bundled_sum_net += bprice.net
bundled_sum_tax += bprice.tax
if not self.tax_rule:
t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
override_tax_rate=override_tax_rate, currency=currency or self.event.currency,
subtract_from_gross=bundled_sum)
if bundled_sum:
t.name = "MIXED!"
t.gross += bundled_sum
t.net += bundled_sum_net
t.tax += bundled_sum_tax
return t
@@ -1001,6 +1008,16 @@ class ItemVariation(models.Model):
return False
return True
@property
def meta_data(self):
data = self.item.meta_data
if hasattr(self, 'meta_values_cached'):
data.update({v.property.name: v.value for v in self.meta_values_cached})
else:
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
class ItemAddOn(models.Model):
"""
@@ -1325,6 +1342,7 @@ class Question(LoggedModel):
verbose_name = _("Question")
verbose_name_plural = _("Questions")
ordering = ('position', 'id')
unique_together = (('event', 'identifier'),)
def __str__(self):
return str(self.question)
@@ -1340,7 +1358,7 @@ class Question(LoggedModel):
@staticmethod
def _clean_identifier(event, code, instance=None):
qs = Question.objects.filter(event=event, identifier__iexact=code)
if instance:
if instance and instance.pk:
qs = qs.exclude(pk=instance.pk)
if qs.exists():
raise ValidationError(_('This identifier is already used for a different question.'))
@@ -1376,8 +1394,10 @@ class Question(LoggedModel):
if self.type == Question.TYPE_CHOICE:
if isinstance(answer, QuestionOption):
return answer
if not isinstance(answer, (int, str)):
raise ValidationError(_('Invalid input type.'))
q = Q(identifier=answer)
if isinstance(answer, int) or answer.isdigit():
if isinstance(answer, int) or (isinstance(answer, str) and answer.isdigit()):
q |= Q(pk=answer)
o = self.options.filter(q).first()
if not o:
@@ -1777,8 +1797,21 @@ class ItemMetaValue(LoggedModel):
class Meta:
unique_together = ('item', 'property')
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
class ItemVariationMetaValue(LoggedModel):
"""
A meta-data value assigned to an item variation, overriding the value on the item.
:param variation: The variation this metadata is valid for
:type variation: ItemVariation
:param property: The property this value belongs to
:type property: ItemMetaProperty
:param value: The actual value
:type value: str
"""
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE, related_name='meta_values')
property = models.ForeignKey('ItemMetaProperty', on_delete=models.CASCADE, related_name='variation_values')
value = models.TextField()
class Meta:
unique_together = ('variation', 'property')

View File

@@ -80,6 +80,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import order_gracefully_delete
from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map
from ._transactions import (
_fail, _transactions_mark_order_clean, _transactions_mark_order_dirty,
)
@@ -268,7 +269,10 @@ class Order(LockModel, LoggedModel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'require_approval' not in self.get_deferred_fields() and 'status' not in self.get_deferred_fields():
self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
self._transaction_key_reset()
def _transaction_key_reset(self):
self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
def gracefully_delete(self, user=None, auth=None):
from . import GiftCard, GiftCardTransaction, Membership, Voucher
@@ -561,17 +565,30 @@ class Order(LockModel, LoggedModel):
@cached_property
def user_cancel_fee(self):
fee = Decimal('0.00')
if self.event.settings.cancel_allow_user_paid_keep_fees:
fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate(
s=Sum('value')
)['s'] or 0
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
if self.status == Order.STATUS_PAID:
if self.event.settings.cancel_allow_user_paid_keep_fees:
fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate(
s=Sum('value')
)['s'] or 0
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
else:
if self.event.settings.cancel_allow_user_unpaid_keep_fees:
fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate(
s=Sum('value')
)['s'] or 0
if self.event.settings.cancel_allow_user_unpaid_keep_percentage:
fee += self.event.settings.cancel_allow_user_unpaid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_unpaid_keep:
fee += self.event.settings.cancel_allow_user_unpaid_keep
return round_decimal(min(fee, self.total), self.event.currency)
@property
@@ -639,10 +656,12 @@ class Order(LockModel, LoggedModel):
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
return False
if self.status == Order.STATUS_PAID or self.payment_refund_sum > Decimal('0.00'):
if self.status == Order.STATUS_PAID:
if self.total == Decimal('0.00'):
return self.event.settings.cancel_allow_user
return self.event.settings.cancel_allow_user_paid
elif self.payment_refund_sum > Decimal('0.00'):
return False
elif self.status == Order.STATUS_PENDING:
return self.event.settings.cancel_allow_user
return False
@@ -746,6 +765,19 @@ class Order(LockModel, LoggedModel):
length += 1
iteration = 0
@property
def modify_deadline(self):
modify_deadline = self.event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
if self.event.has_subevents and modify_deadline:
dates = [
modify_deadline.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
]
return min(dates) if dates else None
elif modify_deadline:
return modify_deadline.datetime(self.event)
return None
@property
def can_modify_answers(self) -> bool:
"""
@@ -758,16 +790,7 @@ class Order(LockModel, LoggedModel):
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
return False
modify_deadline = self.event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
if self.event.has_subevents and modify_deadline:
dates = [
modify_deadline.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
]
modify_deadline = min(dates) if dates else None
elif modify_deadline:
modify_deadline = modify_deadline.datetime(self.event)
modify_deadline = self.modify_deadline
if modify_deadline is not None and now() > modify_deadline:
return False
@@ -974,7 +997,7 @@ class Order(LockModel, LoggedModel):
position and the attendee email will be used if available.
"""
from pretix.base.services.mail import (
SendMailException, TolerantDict, mail, render_mail,
SendMailException, mail, render_mail,
)
if not self.email and not (position and position.attendee_email):
@@ -990,7 +1013,7 @@ class Order(LockModel, LoggedModel):
try:
email_content = render_mail(template, context)
subject = str(subject).format_map(TolerantDict(context))
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
@@ -1020,7 +1043,7 @@ class Order(LockModel, LoggedModel):
with language(self.locale, self.event.settings.region):
email_template = self.event.settings.mail_text_resend_link
email_context = get_email_context(event=self.event, order=self)
email_subject = _('Your order: %(code)s') % {'code': self.code}
email_subject = self.event.settings.mail_subject_resend_link
self.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.resend', user=user, auth=auth,
@@ -1052,12 +1075,14 @@ class Order(LockModel, LoggedModel):
if p.canceled and not _backfill_before_cancellation:
continue
target_transaction_count[Transaction.key(p)] += 1
p._transaction_key_reset()
fees = self.fees.all() if fees is None else fees
for f in fees:
if f.canceled and not _backfill_before_cancellation:
continue
target_transaction_count[Transaction.key(f)] += 1
f._transaction_key_reset()
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
create = []
@@ -1084,6 +1109,7 @@ class Order(LockModel, LoggedModel):
create.sort(key=lambda t: (0 if t.count < 0 else 1, t.positionid or 0))
if save:
Transaction.objects.bulk_create(create)
self._transaction_key_reset()
_transactions_mark_order_clean(self.pk)
return create
@@ -1484,6 +1510,9 @@ class OrderPayment(models.Model):
:type info: str
:param fee: The ``OrderFee`` object used to track the fee for this order.
:type fee: pretix.base.models.OrderFee
:param process_initiated: Only for internal use inside pretix.presale to check which payments have started
the execution process.
:type process_initiated: bool
"""
PAYMENT_STATE_CREATED = 'created'
PAYMENT_STATE_PENDING = 'pending'
@@ -1534,6 +1563,9 @@ class OrderPayment(models.Model):
null=True, blank=True, related_name='payments', on_delete=models.SET_NULL
)
migrated = models.BooleanField(default=False)
process_initiated = models.BooleanField(
null=True # null = created before this field was introduced
)
objects = ScopedManager(organizer='order__event__organizer')
@@ -1620,7 +1652,7 @@ class OrderPayment(models.Model):
}, user=user, auth=auth)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_date=None):
ignore_date=False, lock=True, payment_date=None, generate_invoice=True):
"""
Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required
@@ -1683,10 +1715,11 @@ class OrderPayment(models.Model):
))
return
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum)
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum,
generate_invoice)
def _mark_order_paid(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_refund_sum=0):
ignore_date=False, lock=True, payment_refund_sum=0, allow_generate_invoice=True):
from pretix.base.services.invoices import (
generate_invoice, invoice_qualified,
)
@@ -1703,7 +1736,7 @@ class OrderPayment(models.Model):
ignore_date=ignore_date)
invoice = None
if invoice_qualified(self.order):
if invoice_qualified(self.order) and allow_generate_invoice:
invoices = self.order.invoices.filter(is_cancellation=False).count()
cancellations = self.order.invoices.filter(is_cancellation=True).count()
gen_invoice = (
@@ -1728,8 +1761,8 @@ class OrderPayment(models.Model):
with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid_attendee
email_subject = self.order.event.settings.mail_subject_order_paid_attendee
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
try:
position.send_mail(
email_subject, email_template, email_context,
@@ -1746,8 +1779,8 @@ class OrderPayment(models.Model):
with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid
email_subject = self.order.event.settings.mail_subject_order_paid
email_context = get_email_context(event=self.order.event, order=self.order, 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,
@@ -2072,8 +2105,20 @@ class OrderFee(models.Model):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.get_deferred_fields():
self.__initial_transaction_key = Transaction.key(self)
self.__initial_canceled = self.canceled
self._transaction_key_reset()
def refresh_from_db(self, using=None, fields=None):
"""
Reload field values from the database. Similar to django's implementation
with adjustment for our method that forces us to create ``Transaction`` instances.
"""
if not self.get_deferred_fields():
self._transaction_key_reset()
return super().refresh_from_db(using, fields)
def _transaction_key_reset(self):
self.__initial_transaction_key = Transaction.key(self)
self.__initial_canceled = self.canceled
def __str__(self):
if self.description:
@@ -2193,8 +2238,20 @@ class OrderPosition(AbstractPosition):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.get_deferred_fields():
self.__initial_transaction_key = Transaction.key(self)
self.__initial_canceled = self.canceled
self._transaction_key_reset()
def refresh_from_db(self, using=None, fields=None):
"""
Reload field values from the database. Similar to django's implementation
with adjustment for our method that forces us to create ``Transaction`` instances.
"""
if not self.get_deferred_fields():
self._transaction_key_reset()
return super().refresh_from_db(using, fields)
def _transaction_key_reset(self):
self.__initial_transaction_key = Transaction.key(self)
self.__initial_canceled = self.canceled
class Meta:
verbose_name = _("Order position")
@@ -2203,7 +2260,7 @@ class OrderPosition(AbstractPosition):
@cached_property
def sort_key(self):
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid
@property
def checkins(self):
@@ -2229,7 +2286,7 @@ class OrderPosition(AbstractPosition):
ops = []
cp_mapping = {}
# The sorting key ensures that all addons come directly after the position they refer to
for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))):
for i, cartpos in enumerate(sorted(cp, key=lambda c: c.sort_key)):
op = OrderPosition(order=order)
for f in AbstractPosition._meta.fields:
if f.name == 'addon_to':
@@ -2358,7 +2415,7 @@ class OrderPosition(AbstractPosition):
:param attach_ical: Attach relevant ICS files
"""
from pretix.base.services.mail import (
SendMailException, TolerantDict, mail, render_mail,
SendMailException, mail, render_mail,
)
if not self.attendee_email:
@@ -2371,7 +2428,7 @@ class OrderPosition(AbstractPosition):
recipient = self.attendee_email
try:
email_content = render_mail(template, context)
subject = str(subject).format_map(TolerantDict(context))
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
@@ -2403,7 +2460,7 @@ class OrderPosition(AbstractPosition):
with language(self.order.locale, self.order.event.settings.region):
email_template = self.event.settings.mail_text_resend_link
email_context = get_email_context(event=self.order.event, order=self.order, position=self)
email_subject = _('Your event registration: %(code)s') % {'code': self.order.code}
email_subject = self.event.settings.mail_subject_resend_link
self.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.resend', user=user, auth=auth,
@@ -2625,6 +2682,20 @@ class CartPosition(AbstractPosition):
self.event.currency)
return self.price - net
@cached_property
def sort_key(self):
subevent_key = (self.subevent.date_from, str(self.subevent.name), self.subevent_id) if self.subevent_id else (0, "", 0)
category_key = (self.item.category.position, self.item.category.id) if self.item.category_id is not None else (0, 0)
item_key = self.item.position, self.item_id
variation_key = (self.variation.position, self.variation.id) if self.variation_id is not None else (0, 0)
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else None), self.pk)
sort_key = subevent_key + category_key + item_key + variation_key + line_key
if self.addon_to_id:
return self.addon_to.sort_key + (1 if self.is_bundled else 2,) + sort_key
else:
return sort_key
def update_listed_price_and_voucher(self, voucher_only=False, max_discount=None):
from pretix.base.services.pricing import (
get_listed_price, is_included_for_free,
@@ -2674,12 +2745,18 @@ class CartPosition(AbstractPosition):
tax_rule=self.item.tax_rule,
invoice_address=invoice_address,
bundled_sum=sum([b.price_after_voucher for b in bundled_positions]),
is_bundled=self.is_bundled,
)
if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate:
self.line_price_gross = line_price.gross
self.tax_rate = line_price.rate
self.save(update_fields=['line_price_gross', 'tax_rate'])
@property
def addons_without_bundled(self):
addons = [op for op in self.addons.all() if not op.is_bundled]
return sorted(addons, key=lambda cp: cp.sort_key)
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)

View File

@@ -114,7 +114,7 @@ EU_CURRENCIES = {
'RO': 'RON',
'SE': 'SEK'
}
VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH'}
VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH', 'NO'}
def is_eu_country(cc):

View File

@@ -137,6 +137,8 @@ class Voucher(LoggedModel):
:type max_usages: int
:param redeemed: The number of times this voucher already has been redeemed
:type redeemed: int
:param min_usages: The minimum number of times this voucher must be redeemed
:type min_usages: int
:param valid_until: The expiration date of this voucher (optional)
:type valid_until: datetime
:param block_quota: If set to true, this voucher will reserve quota for its holder
@@ -199,6 +201,14 @@ class Voucher(LoggedModel):
verbose_name=_("Redeemed"),
default=0
)
min_usages = models.PositiveIntegerField(
verbose_name=_("Minimum usages"),
help_text=_("If set to more than one, the voucher must be redeemed for this many products when it is used for "
"the first time. On later usages, it can also be used for lower numbers of products. Note that "
"this means that the total number of usages in some cases can be lower than this limit, e.g. in "
"case of cancellations."),
default=1
)
budget = models.DecimalField(
verbose_name=_("Maximum discount budget"),
help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. "
@@ -350,6 +360,10 @@ class Voucher(LoggedModel):
'redeemed': redeemed
}
)
if data.get('max_usages', 1) < data.get('min_usages', 1):
raise ValidationError(
_('The maximum number of usages may not be lower than the minimum number of usages.'),
)
@staticmethod
def clean_subevent(data, event):
@@ -464,7 +478,7 @@ class Voucher(LoggedModel):
if quota:
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
if data.get('max_usages', 1) > 1:
if data.get('max_usages', 1) > 1 or data.get('min_usages', 1) > 1:
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
if item and seat.product != item:
@@ -567,6 +581,10 @@ class Voucher(LoggedModel):
else:
return bool(subevent.seating_plan) if subevent else self.event.seating_plan
@property
def min_usages_remaining(self):
return max(1, self.min_usages - self.redeemed)
@classmethod
def annotate_budget_used_orders(cls, qs):
opq = OrderPosition.objects.filter(

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