Compare commits

...

256 Commits

Author SHA1 Message Date
Raphael Michel
6ea8a47f17 Try to debug flaky test 2021-03-02 10:43:37 +01:00
Raphael Michel
a09dac89c4 Partially revert de597ba86 2021-03-02 09:30:27 +01:00
Raphael Michel
8af91b691d Allow configurable addition to the order confirmation message 2021-03-01 18:28:08 +01:00
Raphael Michel
2221b57dc9 Allow to disable ticket attachments to emails 2021-03-01 18:21:12 +01:00
Raphael Michel
8d99388c08 InvoiceExporter: Useful error message if PDF generation fails 2021-03-01 10:35:53 +01:00
Raphael Michel
de597ba864 Fix #1982 -- Stricter cleaning of dynamic values in invoices 2021-03-01 10:35:02 +01:00
Raphael Michel
2d9a16e94d Bump to 3.17.0.dev0 2021-02-26 17:48:59 +01:00
Raphael Michel
a0026d8a0c Bump to 3.16.0 2021-02-26 17:48:02 +01:00
Raphael Michel
1cee082821 Translated on translate.pretix.eu (German (informal) (de_Informal))
Currently translated at 100.0% (3996 of 3996 strings)

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

powered by weblate
2021-02-26 17:47:56 +01:00
Raphael Michel
6c1a3a4c68 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3996 of 3996 strings)

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

powered by weblate
2021-02-26 17:47:56 +01:00
Raphael Michel
156e8413f8 Add geo to wordlist.txt 2021-02-26 17:43:00 +01:00
Raphael Michel
46ccce439a PDF: Add placeholder for the event name even in series 2021-02-26 17:39:18 +01:00
Richard Schreiber
675de12a5d Geo fields: only confirm/overwrite if new lat/lon differ from existing coordinates 2021-02-26 15:20:14 +01:00
Raphael Michel
5992892035 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2021-02-26 10:06:30 +01:00
Richard Schreiber
1c81792cd7 Geo fields: Allow overriding existing values (#1978) 2021-02-26 09:55:23 +01:00
Raphael Michel
73e7d407cd Translated on translate.pretix.eu (German (informal) (de_Informal))
Currently translated at 100.0% (3992 of 3992 strings)

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

powered by weblate
2021-02-26 09:48:15 +01:00
Raphael Michel
fa78583cd3 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3992 of 3992 strings)

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

powered by weblate
2021-02-26 09:48:15 +01:00
Raphael Michel
bcba7b70ca Translated on translate.pretix.eu (German (informal) (de_Informal))
Currently translated at 100.0% (3992 of 3992 strings)

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

powered by weblate
2021-02-26 09:48:15 +01:00
Raphael Michel
141c6d04b2 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3992 of 3992 strings)

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

powered by weblate
2021-02-26 09:48:15 +01:00
Raphael Michel
9c0da900a2 Update issue templates 2021-02-25 20:55:12 +01:00
Raphael Michel
580479b266 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2021-02-25 20:14:50 +01:00
Raphael Michel
4adaa2059d Clarify language 2021-02-25 17:23:00 +01:00
Raphael Michel
a900f39121 Check-in list update view: Fix incorrect timezone handling in exit_at_all 2021-02-25 12:32:55 +01:00
Richard Schreiber
b625d987a9 fix encoding issue in geocode-API call 2021-02-24 17:52:55 +01:00
Richard Schreiber
71e7d527d1 Merge pull request #1933 from pretix/a11y-add-landmarks
a11y: add landmarks, add missing labels, aria-hide icons, add checkout notifications to page title
2021-02-24 16:51:09 +01:00
Richard Schreiber
6fd0880e79 Change test, so at least body-element is available and ticket not in there 2021-02-24 16:09:30 +01:00
Richard Schreiber
8ca253c860 Fixed selectors in tests 2021-02-24 15:02:39 +01:00
Richard Schreiber
63c2852668 Merge branch 'master' into a11y-add-landmarks 2021-02-24 13:17:24 +01:00
Richard Schreiber
5b36fa198d Bulk action improvements: buttons (wording, color, icons, disabled-state), hide select-on-all-pages if only one results-page (#1973) 2021-02-24 09:59:07 +01:00
Irmantas
ef8b6f60b8 Adjust runperiodic logging (#1974)
Co-authored-by: Irmantas Marozas <irmantas.marozas@juvare.com>
2021-02-23 15:47:13 +01:00
Richard Schreiber
6ca07662b6 Merge branch 'master' into a11y-add-landmarks 2021-02-23 11:58:18 +01:00
Richard Schreiber
45a499ebba Merge pull request #1931 from pretix/bulk-select-with-drag-over
add bulk selection by click and drag over table rows
2021-02-22 22:00:40 +01:00
Richard Schreiber
1bfa4c6fda update toggle-state after release/pointerup instead of during updateSelection 2021-02-22 18:16:46 +01:00
Richard Schreiber
8a169d0496 fix bug when releasing outside of table 2021-02-22 18:13:18 +01:00
Richard Schreiber
40dbae76ca remove call to console.log 2021-02-22 17:47:03 +01:00
Richard Schreiber
4203087eff removed .warning from selected $rows as it interferes with .table-select-all 2021-02-22 17:46:04 +01:00
Richard Schreiber
88bf31bd7a Merge branch 'master' into bulk-select-with-drag-over 2021-02-22 16:37:39 +01:00
Richard Schreiber
3423923d84 Reduced "tickets and/or products" to "products" 2021-02-22 16:02:45 +01:00
Raphael Michel
beb33e21ee Merge pull request #1927 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2021-02-22 15:24:59 +01:00
Ondřej Sokol
461ab8ba0a Translated on translate.pretix.eu (Czech)
Currently translated at 4.1% (162 of 3940 strings)

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

powered by weblate
2021-02-22 15:22:44 +01:00
Raphael Michel
7562f333cf Subevents: Bulk editor (#1918)
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2021-02-22 15:22:40 +01:00
Richard Schreiber
32f1c32936 reverted source order for lang-nav, kept as nav-element 2021-02-22 14:59:24 +01:00
Raphael Michel
eb0123e350 Allow to inspect organizer-level logs 2021-02-22 10:15:59 +01:00
Raphael Michel
37ba885c55 Check-in rule editor: Set tolerance to 0 when using custom time 2021-02-19 18:12:30 +01:00
Raphael Michel
8330448a94 Fix import order 2021-02-19 16:13:32 +01:00
Raphael Michel
8582bf8158 Use footernav block on organizer page 2021-02-19 16:00:12 +01:00
Richard Schreiber
e872180ed1 add order confirmation to <title> 2021-02-19 12:00:03 +01:00
Richard Schreiber
cc88e70db6 hide reservation timer from screen readers 2021-02-19 11:59:47 +01:00
Richard Schreiber
c335dd35b3 moved notice-bottom in footer, added footernav 2021-02-19 11:58:56 +01:00
Richard Schreiber
cea8efc4a3 added <main> and <aside> to checkout pages 2021-02-19 11:57:45 +01:00
Richard Schreiber
c6c0f92891 moved update()-event to checkboxes’ change-event, added row-highlight if selected 2021-02-19 11:03:47 +01:00
Richard Schreiber
d5950821e2 optimized update() to only check the least number of checkboxes 2021-02-19 11:02:42 +01:00
Richard Schreiber
78f2581bb8 added labels to batch-select checkboxes 2021-02-19 11:00:38 +01:00
Richard Schreiber
c9f89dc920 simplified selection algorithm 2021-02-19 07:33:01 +01:00
Richard Schreiber
fb7d38ede0 add bulk selection by click and drag over table rows 2021-02-18 21:05:30 +01:00
Raphael Michel
8be2f9ad6b XLSX exports: Strip all illegal characters 2021-02-17 17:34:08 +01:00
Richard Schreiber
c033efbfa2 Merge pull request #1929 from pretix/fix-add-line-breaks-to-voucher-list
Voucher creation: Add linebreaks to {voucher_list} in emails
2021-02-17 15:35:26 +01:00
Richard Schreiber
d990f0e927 fix markdown-linebreaks for voucher_list 2021-02-17 15:23:16 +01:00
Richard Schreiber
e011b7810d Merge pull request #1928 from pretix/mark-as-paid-add-notify-checkbox
Add checkbox to disable email sending when marking an order as paid
2021-02-17 14:58:35 +01:00
Richard Schreiber
0d0bbe1ce5 add send_email field to mark-paid 2021-02-17 12:37:26 +01:00
Raphael Michel
488273d5f2 Fix #1912 -- Auto-open all <details> with an error inside 2021-02-15 18:30:37 +01:00
Raphael Michel
9fdaf040dc Asynctask JS: On errors, only replace inner part of page 2021-02-15 18:30:37 +01:00
Raphael Michel
d109dde1e1 Fix form validation of exporters (again) 2021-02-15 18:30:37 +01:00
Raphael Michel
d713398e88 Merge pull request #1925 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2021-02-15 18:02:34 +01:00
Jaakko Rinta-Filppula
0898d13e4c Translated on translate.pretix.eu (Finnish)
Currently translated at 18.5% (731 of 3940 strings)

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

powered by weblate
2021-02-15 17:48:50 +01:00
Raphael Michel
04098ce002 API: Add docs on order lifecycle 2021-02-15 17:48:43 +01:00
Richard Schreiber
f2a18325b6 Add a voucher’s comment to voucher.csv download (#1926) 2021-02-15 13:41:44 +01:00
Raphael Michel
4db0530c09 REST API docs: Remove "versionchanged" notes older than ~1 year (version 3.2 and below) 2021-02-15 09:43:58 +01:00
Raphael Michel
938d84b251 Update order state chart 2021-02-15 09:38:13 +01:00
Raphael Michel
c65b2aa4f8 API: Add missing field SubEvent.frontpage_text 2021-02-15 09:16:40 +01:00
Richard Schreiber
2583e6166a Merge branch 'master' into a11y-add-landmarks 2021-02-12 20:40:24 +01:00
Richard Schreiber
825fd1820b [a11y] Add text "required" to label of required inputs (#1923) 2021-02-12 14:05:30 +01:00
Raphael Michel
c8d039b196 Sendmail: Allow to filter by order date 2021-02-12 12:41:45 +01:00
Raphael Michel
72b6ff0389 Sendmail form: Fix validation problems 2021-02-12 12:34:04 +01:00
Richard Schreiber
ef4db07e8b omit ? in lang-nav redirect when not needed 2021-02-11 17:27:05 +01:00
Richard Schreiber
ef1e5759eb marked checkout-steps as completed/current
See https://www.w3.org/WAI/tutorials/forms/multi-page/
2021-02-11 17:26:14 +01:00
Richard Schreiber
9f1079dcc4 checkout-flow steps to page-title 2021-02-11 17:25:31 +01:00
Richard Schreiber
518c1fbbf2 Added notifications/messages to page-title 2021-02-11 17:24:05 +01:00
Richard Schreiber
b9c9a03cdd Fixed item.name for price input 2021-02-11 16:16:17 +01:00
Richard Schreiber
5060bac7e0 update aria-label on <main> 2021-02-11 16:04:07 +01:00
Richard Schreiber
c4be508e26 added anchor link to voucher-input 2021-02-11 16:03:47 +01:00
Richard Schreiber
c75f741d4f removed variations-toggle hreef-attribute to enable non-JS dialog-open behaviour 2021-02-11 16:03:13 +01:00
Richard Schreiber
d6ef563f83 added labels (price input, image-lightbox link) 2021-02-11 16:02:18 +01:00
Richard Schreiber
3f75a935a3 changed products to <article> 2021-02-10 18:03:30 +01:00
Richard Schreiber
246e7c9443 added aria-labels to category-sections 2021-02-10 18:03:09 +01:00
Richard Schreiber
3dd685bf7a Updated main aria-label 2021-02-10 17:55:30 +01:00
Richard Schreiber
1480bd0690 added aria-hidden to fontawesome and some aria-label if needed 2021-02-10 17:27:49 +01:00
Richard Schreiber
01af8568ca converted second lang-nav to <nav> and changed source order 2021-02-10 16:41:59 +01:00
Richard Schreiber
74461dde50 added landmarks to startpage 2021-02-10 16:36:31 +01:00
Raphael Michel
f0fd4272dc Add more features to custom meta properties (#1922) 2021-02-10 11:01:25 +01:00
Raphael Michel
a0f60c71b9 Add order time to check-in list CSV export 2021-02-10 09:11:16 +01:00
Raphael Michel
6b2ab44b26 Fix undefined variable 2021-02-09 19:00:20 +01:00
Raphael Michel
9472d81e55 Invalidate ticket cache after a change in events or subevents 2021-02-09 18:33:04 +01:00
Raphael Michel
b630174f72 Fix bug when modifying an order with an address in a country with a state 2021-02-09 18:23:24 +01:00
Raphael Michel
25c35b0f73 Docker: Log more things to stdout 2021-02-09 18:16:35 +01:00
Raphael Michel
c0792f4171 Fallback to random ticket secret generator if invalid one is selected 2021-02-09 16:01:33 +01:00
Raphael Michel
5d490728df Check-in: Do not respond with outdated question answers in pdf_data 2021-02-09 12:55:27 +01:00
Raphael Michel
21fbf095cf Fix compatibility with cryptography 3.4.x 2021-02-08 18:01:05 +01:00
Raphael Michel
7b8ad1ebbe Remove --no-use-pep517 2021-02-08 17:56:05 +01:00
Raphael Michel
81f37d9ce5 PDF layout: Allow to show photos from questions (#1919) 2021-02-08 17:48:06 +01:00
Raphael Michel
40c4872459 Check-in: Save answers independent of result 2021-02-08 17:01:55 +01:00
Martin Gross
f0574755a2 Add salutation_given_family to PERSON_NAME_SCHEMES. (#1921) 2021-02-05 13:31:57 +01:00
Raphael Michel
4cfedebf3b Fix scoping issue in tests 2021-02-05 12:53:55 +01:00
Raphael Michel
45376dd757 Fix linter issue 2021-02-04 21:43:04 +01:00
Raphael Michel
0999f41b0c Do not allow modifications after checkin 2021-02-04 21:43:04 +01:00
Raphael Michel
565f77d13b Add imprint and contact mail on organizer level 2021-02-04 17:36:29 +01:00
Raphael Michel
5ae7a350b0 Add signal global_footer_link 2021-02-04 17:32:10 +01:00
Raphael Michel
af7d9942f6 Sort payment providers by public name 2021-02-04 17:21:47 +01:00
Raphael Michel
36efb25b98 Invoice address: Always validate that VAT ID is for correct country 2021-02-04 17:21:24 +01:00
Raphael Michel
7a496da945 Widget: Introduce disable-iframe parameter 2021-02-04 10:58:15 +01:00
Raphael Michel
03f1016cc7 Fix syntax error in setup.py 2021-02-04 10:42:41 +01:00
Raphael Michel
4d4d2d5fe7 Fix ill-formed requirement 2021-02-04 10:24:04 +01:00
Raphael Michel
98f48e78a8 Update bleach to 3.3.0 2021-02-04 10:23:18 +01:00
Raphael Michel
512c9f5301 Upgrade bootstrap JS to 3.4.1 2021-02-04 10:20:49 +01:00
Raphael Michel
d16c59e86c Update Vue.js from 2.4.0 to 2.6.12 2021-02-04 10:06:51 +01:00
Raphael Michel
177b0505fd Update moment.js from 2.14 to 2.29 2021-02-04 10:06:38 +01:00
Raphael Michel
01f7a70347 ADd some extensibility features to MultiSheetListExporter 2021-02-03 17:29:15 +01:00
Raphael Michel
3b4c99d450 Rich text: Fix issue with <a> without href="" 2021-02-02 15:58:43 +01:00
Raphael Michel
3bb23bb77e Invoice exporter: Add order ID to filename 2021-02-02 15:54:05 +01:00
Raphael Michel
89da0847ca Device security policy: Allow order payments for POS 2021-02-02 15:54:05 +01:00
Martin Gross
07bed72b5e Add information on `subevent`-parameter for pretix-Button 2021-02-01 16:08:16 +01:00
Raphael Michel
c103288eec 2021 attempt at disabling autocomplete in date fields
Apparently, we so far disabled "autofill" but not "autocomplete". For
date fields, autocomplete is more relevant. Explanation
https://stackoverflow.com/a/57810447/336784
2021-02-01 10:07:57 +01:00
Raphael Michel
03648b77b1 List of teams: Order alphabetically 2021-02-01 10:07:57 +01:00
Raphael Michel
818d75ddd7 Merge pull request #1917 from pretix-translations/weblate-pretix-pretix 2021-01-30 21:02:25 +01:00
Raphael Michel
20f608caae Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3940 of 3940 strings)

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

powered by weblate
2021-01-30 20:49:41 +01:00
Raphael Michel
7b3a6d47fc Translated on translate.pretix.eu (German (informal) (de_Informal))
Currently translated at 100.0% (3940 of 3940 strings)

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

powered by weblate
2021-01-30 20:49:26 +01:00
Maarten van den Berg
d586406c79 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3940 of 3940 strings)

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

powered by weblate
2021-01-29 12:23:33 +01:00
Maarten van den Berg
e214c8cb95 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3940 of 3940 strings)

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

powered by weblate
2021-01-29 12:23:31 +01:00
Raphael Michel
81f2b9db30 Fix typo in docstring 2021-01-29 10:32:35 +01:00
Raphael Michel
04a6ed20b9 Bump to 3.16.0.dev0 2021-01-29 10:14:45 +01:00
Raphael Michel
d745bcf2c4 Bump to 3.15.0 2021-01-29 10:14:45 +01:00
Raphael Michel
a1bfe05879 Correctly pass query string when redirecting to sudo page 2021-01-28 22:22:26 +01:00
Raphael Michel
f156299cb3 Merge pull request #1915 from pretix-translations/weblate-pretix-pretix 2021-01-28 12:58:25 +01:00
pretix Translation Platform
023b1535d4 Fix corrupt file 2021-01-28 12:53:18 +01:00
Raphael Michel
ec97dae695 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3934 of 3934 strings)

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

powered by weblate
2021-01-28 12:49:48 +01:00
Raphael Michel
f184ca1918 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3934 of 3934 strings)

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

powered by weblate
2021-01-28 12:49:48 +01:00
Jaakko Rinta-Filppula
7f71ae6e4b Translated on translate.pretix.eu (Finnish)
Currently translated at 18.3% (721 of 3934 strings)

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

powered by weblate
2021-01-28 12:49:47 +01:00
Jaakko Rinta-Filppula
84bafd94d5 Translated on translate.pretix.eu (Finnish)
Currently translated at 18.2% (713 of 3909 strings)

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

powered by weblate
2021-01-28 12:49:46 +01:00
Raphael Michel
ac7502b0a2 Limit batch size in bulk voucher creation (avoid problems with MySQL's max_packet_size) 2021-01-28 12:43:26 +01:00
Raphael Michel
3c85591568 Fix typos 2021-01-28 10:41:40 +01:00
Raphael Michel
2787935fc6 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2021-01-27 18:45:35 +01:00
Raphael Michel
6d432cf824 Bank transfer: Allow to add IBAN blocklist for refunds 2021-01-27 18:44:20 +01:00
Raphael Michel
e09853c6c6 SubEvent editing: Pass copy_from to plugins 2021-01-27 18:22:36 +01:00
Raphael Michel
418c9196ba Order list: Sort by cancellation request time if filtered for requested cancellations 2021-01-27 12:37:54 +01:00
Raphael Michel
a949fd7fdc Bank transfer: Change order of variables in export 2021-01-27 11:09:07 +01:00
Raphael Michel
f9b834b798 Bank transfer: Duplicate payments are allowed if part of the same import 2021-01-27 10:52:20 +01:00
Raphael Michel
0747f5b8b8 Freshen up layout of refund choice page 2021-01-27 10:48:40 +01:00
Raphael Michel
33b34f31d1 Add BasePaymentProvider.payment_control_render_short and use it on refund page 2021-01-27 10:34:59 +01:00
Raphael Michel
f93c780e6a Add context to "to" translation 2021-01-27 10:05:31 +01:00
Raphael Michel
9722e76e5f Bank transfer refund: Allow to enter BIC 2021-01-27 10:04:31 +01:00
Raphael Michel
e33d15429e ResendLinkView: Change text and logic of rate limiting 2021-01-26 16:52:08 +01:00
Raphael Michel
41c69aaa2a Allow to create refunds without a payment (#1914)
Co-authored-by: Martin Gross <gross@rami.io>
2021-01-26 10:53:59 +01:00
pretix translation bot
07ed7526c0 Translations update from Weblate (#1913)
Co-authored-by: Jaakko Rinta-Filppula <jaakko@r-f.fi>
2021-01-26 10:53:45 +01:00
Raphael Michel
1043824853 Add context to ambigous string 2021-01-25 12:40:10 +01:00
Martin Gross
a99a254f5c Add missing logentry-message for denied password reset 2021-01-25 11:48:09 +01:00
Raphael Michel
0429a0f811 Hide DEBUG log from asyncio 2021-01-25 10:40:20 +01:00
Raphael Michel
c2ba312bad Show local time zone of visitor in more places 2021-01-24 22:43:01 +01:00
Raphael Michel
a3ff3cda12 Bank transfer: Freshen up export templates a little 2021-01-24 22:05:18 +01:00
Raphael Michel
aeba2a1e26 Add metrics on celery queue length 2021-01-23 23:49:58 +01:00
Raphael Michel
e57291914c Item list: Add more icons 2021-01-22 15:35:26 +01:00
Raphael Michel
7165cc4c3b Show ID of items and variations in backend 2021-01-22 14:48:32 +01:00
Raphael Michel
fa5f33d3c6 Revert accidental change 2021-01-22 14:44:38 +01:00
Richard Schreiber
c8df9c187e Updated docs on how to setup Google Analytics (Universal or GA4) for cross-domain tracking when using pretix-widget (#1910) 2021-01-20 18:14:45 +01:00
Raphael Michel
35270e7032 Allow to change order of refund list 2021-01-20 18:10:40 +01:00
Raphael Michel
898ae3e2bc Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2021-01-20 17:42:16 +01:00
Raphael Michel
76d0c7be3a Fix a typo 2021-01-20 17:41:43 +01:00
Raphael Michel
793832402c Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2021-01-20 17:33:17 +01:00
Raphael Michel
f6a500cd75 Fix isort issue 2021-01-20 17:27:38 +01:00
Raphael Michel
7a8f90478a CartAdd with subevents: Useful redirect in error case 2021-01-20 17:26:47 +01:00
Raphael Michel
6ea4315beb Subevent bulk creation: Allow to auto-generate time slots 2021-01-20 17:19:01 +01:00
Raphael Michel
f3de5d5c96 Lazy-format placeholders in DateField and TimeField 2021-01-20 17:19:00 +01:00
Raphael Michel
fdc555f74f Merge pull request #1911 from pretix-translations/weblate-pretix-pretix 2021-01-20 17:12:16 +01:00
Jaakko Rinta-Filppula
2505389e61 Translated on translate.pretix.eu (Finnish)
Currently translated at 63.3% (81 of 128 strings)

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

powered by weblate
2021-01-20 17:10:46 +01:00
Jaakko Rinta-Filppula
da38396191 Translated on translate.pretix.eu (Finnish)
Currently translated at 17.8% (695 of 3909 strings)

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

powered by weblate
2021-01-20 17:10:46 +01:00
Jaakko Rinta-Filppula
2abe744bdd Translated on translate.pretix.eu (Finnish)
Currently translated at 17.4% (682 of 3909 strings)

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

powered by weblate
2021-01-20 17:10:46 +01:00
Jaakko Rinta-Filppula
ce79bfb242 Translated on translate.pretix.eu (Finnish)
Currently translated at 17.1% (670 of 3909 strings)

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

powered by weblate
2021-01-20 17:10:46 +01:00
Ayan Ginet
748cfa3487 Documentation typo fix (#1908) 2021-01-20 17:10:40 +01:00
Raphael Michel
eb80cf248e Fix widget tests 2021-01-20 13:16:38 +01:00
Raphael Michel
65e3efa5a3 API: Clarify session validity codepaths 2021-01-20 12:37:34 +01:00
Raphael Michel
3388c3ab09 Add file upload to security profiles 2021-01-20 12:37:29 +01:00
Raphael Michel
65ff065f02 Widget: Show event name and description on level 2 in org > event > date navigation 2021-01-19 16:41:18 +01:00
Raphael Michel
0f30958937 Fix copy-paste error 2021-01-18 15:02:30 +01:00
Raphael Michel
5cef80d58c Subevent creation: Pre-fill name and location from event 2021-01-18 13:07:07 +01:00
Raphael Michel
19c328b6e7 Revert usage of NumberField for geo_lat/geo_lon, causes trouble with German locale 2021-01-18 12:47:40 +01:00
Raphael Michel
fc6b644587 End infinite link tree on organizer calendar pages 2021-01-18 11:13:27 +01:00
Raphael Michel
190ffe8d24 Bank transfer: Move discard button to other end 2021-01-15 12:25:27 +01:00
Raphael Michel
18eedd8a5f Order cancellation: Allow to set step size for fee amount 2021-01-15 12:18:51 +01:00
Raphael Michel
00667aff11 Advanced order search: Allow to search by paid amount 2021-01-15 11:39:28 +01:00
Raphael Michel
f1cd46f6dc Add new field OrderRefund.comment 2021-01-15 11:25:09 +01:00
Raphael Michel
674d7673ce OrderTaxListReport: Fix date handling error 2021-01-15 10:16:01 +01:00
Raphael Michel
71800074ca Bump sepaxml version 2021-01-15 09:40:09 +01:00
Raphael Michel
a7b331a9b0 Bank transfer: Fix mismatch if the reference contains the code twice 2021-01-14 17:49:31 +01:00
Raphael Michel
1d541df381 Bank transfer: Find order codes even if there's a suffix behind them 2021-01-14 16:19:01 +01:00
Raphael Michel
32d32d68d9 Bank transfer: Fix possible importer issue with special regex characters in event slug 2021-01-14 16:13:43 +01:00
Raphael Michel
5375f6aec1 rich text truelinks: Excape - in regex correctly 2021-01-14 13:22:40 +01:00
alice
99f3360c44 typo in banktransfer import filter 2021-01-13 18:15:41 +01:00
Raphael Michel
d391312aab API: Allow to modify order position information (#1904) 2021-01-13 14:18:58 +01:00
Raphael Michel
70bf422537 Do not allow slugs to end with a dot 2021-01-13 11:55:24 +01:00
Raphael Michel
86932e8a19 Merge pull request #1896 from pretix-translations/weblate-pretix-pretix 2021-01-13 10:41:36 +01:00
Maarten van den Berg
2d9bf5ecb9 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3909 of 3909 strings)

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

powered by weblate
2021-01-13 06:00:11 +01:00
Maarten van den Berg
c4e8da8ea4 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3909 of 3909 strings)

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

powered by weblate
2021-01-13 06:00:10 +01:00
Li Thomas
715fdadf95 Translated on translate.pretix.eu (Chinese (Simplified))
Currently translated at 78.6% (3074 of 3909 strings)

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

powered by weblate
2021-01-12 17:32:19 +01:00
Jaakko Rinta-Filppula
1b53d74aa9 Translated on translate.pretix.eu (Finnish)
Currently translated at 17.1% (669 of 3909 strings)

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

powered by weblate
2021-01-12 17:32:19 +01:00
Jaakko Rinta-Filppula
66621aee6e Translated on translate.pretix.eu (Finnish)
Currently translated at 63.3% (81 of 128 strings)

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

powered by weblate
2021-01-12 17:32:19 +01:00
Jaakko Rinta-Filppula
18333041bb Translated on translate.pretix.eu (Finnish)
Currently translated at 16.9% (662 of 3909 strings)

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

powered by weblate
2021-01-12 17:32:19 +01:00
Jaakko Rinta-Filppula
b4badaa472 Translated on translate.pretix.eu (Finnish)
Currently translated at 16.8% (655 of 3909 strings)

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

powered by weblate
2021-01-12 17:32:19 +01:00
Raphael Michel
a856f29426 Rich text: Do not call link text heuristic for mailto: 2021-01-12 17:31:58 +01:00
Raphael Michel
1dab5149d4 Add frontpage_text to widget tests 2021-01-12 13:00:14 +01:00
Raphael Michel
4e870b7366 Question form: Do not show "This field is required" twice 2021-01-12 12:57:00 +01:00
Martin Gross
a8cbb06bb0 Fixing all-time favorite pretix/prefix typo 2021-01-12 12:44:51 +01:00
Raphael Michel
0be2043ded Redirect to single-exporter page after exporter failure 2021-01-12 12:42:37 +01:00
Raphael Michel
2554c7f5fc Fix error 405 in export form validation 2021-01-12 12:38:36 +01:00
Raphael Michel
3912ceb79d Fix widget error 2021-01-12 12:31:32 +01:00
Raphael Michel
593fc69d0c Widget: Include frontpage_text, but only after navigation 2021-01-12 11:57:54 +01:00
Raphael Michel
cf3c4d26cb Bank transfer: Allow to refund payments without BIC 2021-01-08 23:21:49 +01:00
Raphael Michel
bc8358cd97 Bank transfer: Recognize BICs in MT940 2021-01-08 23:21:49 +01:00
Benjamin Hättasch
e2461ab475 Add option to export multiple choice answers in orderlists grouped (#1898) 2021-01-08 21:36:49 +01:00
Raphael Michel
f97c97e661 Fix UX quirk in phone number field triggered by American numbers 2021-01-08 15:18:56 +01:00
Raphael Michel
1325cf1e7c Fix typo 2021-01-07 19:00:54 +01:00
Raphael Michel
ba8ea0e4d4 Resend links: use order.open URL 2021-01-07 18:10:41 +01:00
Raphael Michel
1c769f2876 Order cancel: Show refund methods 2021-01-07 17:55:30 +01:00
Raphael Michel
2dee222482 Payment plugin API: Add payment_presale_render 2021-01-07 17:55:27 +01:00
Raphael Michel
d132cd27f3 PayPal: Do not allow refund if payment is older than 180 days 2021-01-07 17:11:03 +01:00
Raphael Michel
9a2a4bedeb Docs: Add missing placeholders 2021-01-07 15:43:34 +01:00
Raphael Michel
779cefeaad Order codes: Remove character '8' from charset 2021-01-07 15:36:05 +01:00
Raphael Michel
b36feb229f Merge pull request #1901 from pretix/api-upload 2021-01-07 13:00:57 +01:00
Raphael Michel
2e5861958d API: Fix CSS generation after change in event settings 2021-01-07 12:41:41 +01:00
Raphael Michel
01c3b08583 API: Allow to answer file upload questions during ticket redemption 2021-01-07 11:18:23 +01:00
Raphael Michel
5b81507600 API: Allow to use uploaded files in settings fields 2021-01-07 11:18:23 +01:00
Raphael Michel
75e100f108 API: Allow to set product pictures 2021-01-07 11:18:23 +01:00
Raphael Michel
8b08b43e77 API: File upload infrastructure 2021-01-07 11:18:23 +01:00
Raphael Michel
9d70fd675c Badges: Use meaningful filename for downloaded files 2021-01-07 10:35:02 +01:00
Raphael Michel
72504cd53a Context processor: Fix crash if plugin returns none 2021-01-07 10:29:29 +01:00
Raphael Michel
9056826b68 Waiting list: Correct status in backend 2021-01-07 10:29:22 +01:00
Raphael Michel
ecf05b2392 Waiting list: Show warning if event is not live 2021-01-07 10:25:00 +01:00
Raphael Michel
4aa9f073b3 Waiting list: Do not send vouchers for unavailable items 2021-01-07 10:17:45 +01:00
Raphael Michel
19c2b8d89d Check-in rules: Fall back to date_from if date_to is unset 2021-01-07 10:17:29 +01:00
Raphael Michel
5e355b4005 Fix duplicate listing of fonts in event settings 2021-01-05 09:23:00 +01:00
Raphael Michel
746c140cdb Fix crash on failed geocoding 2021-01-04 16:48:32 +01:00
Raphael Michel
be413693ce Validate range of geo_lat/geo_lon values 2021-01-04 10:35:42 +01:00
Raphael Michel
6cf1074b8d Fix geocoding with opencage 2021-01-04 10:35:41 +01:00
Raphael Michel
504067f325 Fix tests failing in 2021 2021-01-01 21:29:51 +01:00
Maico Timmerman
b1cffe9f72 Shredder: Only force download for tax-relevant data (#1801) 2021-01-01 20:20:42 +01:00
Maico Timmerman
c0dd631774 Show "live issues" when event is already live (#1889) 2021-01-01 20:14:04 +01:00
Raphael Michel
66cd63036c Merge pull request #1894 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2021-01-01 20:03:57 +01:00
albert
29a45d3ee4 Translated on translate.pretix.eu (Catalan)
Currently translated at 51.0% (1995 of 3909 strings)

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

powered by weblate
2020-12-30 13:11:22 +01:00
Martin Gross
23aba9b5ef Move Mapquest-Geocoding to HTTPS 2020-12-30 13:11:10 +01:00
Raphael Michel
454f0f6fc8 Create log entry upon order email confirmation 2020-12-23 17:52:20 +01:00
Raphael Michel
002ff38fba Fix crash in add-on form (PRETIXEU-3GV) 2020-12-23 17:46:52 +01:00
luto
dc8bd59715 Add convenience redirect …/event/(org)/ => …/organizer/(org)/ (#1893) 2020-12-23 16:50:41 +01:00
Raphael Michel
56a2da08df Merge pull request #1891 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-12-23 16:31:41 +01:00
Maarten van den Berg
4762d6818f Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3909 of 3909 strings)

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

powered by weblate
2020-12-23 16:31:29 +01:00
Richard Schreiber
e99e91d20f Show event’s date and location in widget if event is subevent (#1892) 2020-12-23 16:31:24 +01:00
0xflotus
9fee2d0fbc Docs: Enabling Syntax Highlighting (#1890) 2020-12-23 10:24:39 +01:00
Raphael Michel
3f30ddc9ab Fix #1888 -- UnknownLocaleError if locale is set 2020-12-22 13:14:56 +01:00
Raphael Michel
641a848f30 Bump to 3.15.0.dev0 2020-12-22 12:40:04 +01:00
324 changed files with 128500 additions and 99109 deletions

23
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,23 @@
---
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.

View File

@@ -33,7 +33,7 @@ jobs:
- name: Install system packages
run: sudo apt update && sudo apt install enchant hunspell aspell-en
- name: Install Dependencies
run: pip3 install --no-use-pep517 -Ur doc/requirements.txt
run: pip3 install -Ur doc/requirements.txt
- name: Spellcheck docs
run: make spelling
working-directory: ./doc

View File

@@ -31,7 +31,7 @@ jobs:
- name: Install system packages
run: sudo apt update && sudo apt install gettext
- name: Install Dependencies
run: pip3 install --no-use-pep517 -Ur src/requirements.txt
run: pip3 install -Ur src/requirements.txt
- name: Compile messages
run: python manage.py compilemessages
working-directory: ./src
@@ -56,7 +56,7 @@ jobs:
- name: Install system packages
run: sudo apt update && sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
- name: Install Dependencies
run: pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
run: pip3 install -Ur src/requirements/dev.txt
- name: Spellcheck translations
run: potypo
working-directory: ./src

View File

@@ -29,7 +29,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install Dependencies
run: pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
run: pip3 install -Ur src/requirements/dev.txt
- name: Run isort
run: isort -c .
working-directory: ./src
@@ -49,7 +49,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install Dependencies
run: pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt
run: pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
- name: Run flake8
run: flake8 .
working-directory: ./src

View File

@@ -57,7 +57,7 @@ jobs:
- name: Install system dependencies
run: sudo apt update && sudo apt install gettext mysql-client
- name: Install Python dependencies
run: pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt mysqlclient psycopg2-binary
run: pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt mysqlclient psycopg2-binary
- name: Run checks
run: python manage.py check
working-directory: ./src

View File

@@ -2,9 +2,8 @@
file=/tmp/supervisor.sock
[supervisord]
logfile=/tmp/supervisord.log
logfile_maxbytes=50MB
logfile_backups=10
logfile=/dev/stdout
logfile_maxbytes=0
loglevel=info
pidfile=/tmp/supervisord.pid
nodaemon=false

View File

@@ -3,5 +3,7 @@ command=/usr/sbin/nginx
autostart=true
autorestart=true
priority=10
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0

View File

@@ -4,3 +4,7 @@ autostart=true
autorestart=true
priority=5
user=pretixuser
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0

View File

@@ -5,3 +5,7 @@ autorestart=true
priority=5
user=pretixuser
environment=HOME=/pretix
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0

View File

@@ -135,7 +135,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
user=pretix
; Replace with the password you chose above
password=*********
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjuts
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjust
; this to wherever your database is running, e.g. the name of a linked container
; or of a mounted MySQL socket.
host=172.17.0.1

View File

@@ -95,6 +95,12 @@ pretix_model_instances
the ``model`` name. Starting with pretix 3.11, these numbers might only be approximate for
most tables when running on PostgreSQL to mitigate performance impact.
pretix_celery_tasks_queued_count
The number of background tasks in the worker queue, labeled with ``queue``.
pretix_celery_tasks_queued_age_seconds
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
.. _Prometheus: https://prometheus.io/
.. _cProfile: https://docs.python.org/3/library/profile.html

View File

@@ -183,6 +183,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
constructed from a number of
days before the base point
and the base point.
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
specifiers in requests
(see below).
===================== ============================ ===================================
Query parameters
@@ -227,4 +230,48 @@ We store idempotency keys for 24 hours, so you should never retry a request afte
All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a
``GET``, ``HEAD``, or ``OPTIONS`` request has no effect.
File upload
-----------
In some places, the API supports working with files, for example when setting the picture of a product. In this case,
you will first need to make a separate request to our file upload endpoint:
.. sourcecode:: http
POST /api/v1/upload HTTP/1.1
Host: pretix.eu
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
Content-Type: image/png
Content-Disposition: attachment; filename="logo.png"
Content-Length: 1234
<raw file content>
Note that the ``Content-Type`` and ``Content-Disposition`` headers are required. If the upload was successful, you will
receive a JSON response with the ID of the file:
.. sourcecode:: http
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
}
You can then use this file ID in the request you want to use it in. File IDs are currently valid for 24 hours and can only
be used using the same authorization method and user that was used to upload them.
.. sourcecode:: http
PATCH /api/v1/organizers/test/events/test/items/3/ HTTP/1.1
Host: pretix.eu
Content-Type: application/json
{
"picture": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
}
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax

View File

@@ -8,4 +8,5 @@ This part of the documentation contains how-to guides on some special use cases
.. toctree::
:maxdepth: 2
order_lifecycle
custom_checkout

View File

@@ -0,0 +1,56 @@
Understanding the life cycle of orders
======================================
When integrating pretix with other systems, it is important that you understand how orders and related objects
such as order positions, fees, payments, refunds, and invoices work together, in order to react to their changes
properly and map them to processes in your system.
Order states
------------
Generally, an order can be in six states. For compatibility reasons, the ``status`` field only allows four values
and the two remaining states are modeled through the ``require_approval`` field and the number of positions within
an order. The states and their allowed changes are shown in the following graph:
.. image:: /images/order_states.png
Object types
------------
Order
One order represents one purchase. It's the main object you interact with and bundles all the other objects
together. Orders can change in many ways during their lifetime, but will never be deleted (unless ``testmode``
is set to ``true``).
Order position
An order position represents one product contained in the order. Orders can usually have multiple positions.
There might be a parent-child relation between order positions if one position is an add-on to another position.
Order positions can change in many ways during their lifetime, and can also be removed or added to an order.
Order fees
A fee represents a charge that is not related to a product. Examples include shipping fees, service fees, and
cancellation fees.
Order fees can change in many ways during their lifetime, and can also be removed or added to an order.
Order payment
An order payment represents one payment attempt with a specific payment method and amount. An order can have
multiple payments attached.
Order payments have their own state diagram. Apart from their state and their meta information (e.g. used
credit card, …) they usually don't change. They may be added at any time, but will never be deleted.
Order refund
An order payment represents one refund attempt with a specific payment method and amount. An order can have
multiple refunds attached.
Order refunds have their own state diagram. Apart from their state and their meta information (e.g. used
credit card, …) they usually don't change. They may be added at any time, but will never be deleted.
Invoice
An invoice represents a legal document stating the contents of an order. While the backend technically allows
to update an invoice in some situations, invoices are generally considered immutable. Once they are issued,
they no longer change. If the order changes substantially (e.g. prices change), an invoice is canceled through
creation of a new invoice with the opposite amount, plus the issuance of a new invoice.
Here's an example of how they all play together:
.. image:: /images/order_objects.png

View File

@@ -42,10 +42,6 @@ seat objects The assigned se
└ seat_guid string Identifier of the seat within the seating plan
===================================== ========================== =======================================================
.. versionchanged:: 1.17
This resource has been added.
.. versionchanged:: 3.0
This ``seat`` attribute has been added.

View File

@@ -25,14 +25,6 @@ is_addon boolean If ``true``, it
defining add-ons for other products.
===================================== ========================== =======================================================
.. versionchanged:: 1.14
The operations POST, PATCH, PUT and DELETE have been added.
.. versionchanged:: 1.16
The field ``internal_name`` has been added.
Endpoints
---------

View File

@@ -36,22 +36,6 @@ rules object Custom check-in
exit_all_at datetime Automatically check out (i.e. perform an exit scan) at this point in time. After this happened, this property will automatically be set exactly one day into the future. Note that this field is considered "internal configuration" and if you pull the list with ``If-Modified-Since``, the daily change in this field will not trigger a response.
===================================== ========================== =======================================================
.. versionchanged:: 1.10
This resource has been added.
.. versionchanged:: 1.11
The ``positions`` endpoints have been added.
.. versionchanged:: 1.13
The ``include_pending`` field has been added.
.. versionchanged:: 3.2
The ``auto_checkin_sales_channels`` field has been added.
.. versionchanged:: 3.9
The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``,
@@ -68,10 +52,6 @@ exit_all_at datetime Automatically c
Endpoints
---------
.. versionchanged:: 1.15
The ``../status/`` detail endpoint has been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/
Returns a list of all check-in lists within a given event.
@@ -380,29 +360,6 @@ Endpoints
Order position endpoints
------------------------
.. versionchanged:: 1.15
The order positions endpoint has been extended by the filter queries ``item__in``, ``variation__in``,
``order__status__in``, ``subevent__in``, ``addon_to__in``, and ``search``. The search for attendee names and order
codes is now case-insensitive.
The ``.../redeem/`` endpoint has been added.
.. versionchanged:: 2.0
The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``.
.. versionchanged:: 2.7
The resource now contains the new attributes ``require_attention`` and ``order__status`` and accepts the new
``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint
returns ``400`` instead of ``404`` on tickets which are known but not paid.
.. versionchanged:: 3.2
The ``checkins`` dict now also contains a ``auto_checked_in`` value to indicate if the check-in has been performed
automatically by the system.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
Returns a list of all order positions within a given event. The result is the same as

View File

@@ -52,31 +52,6 @@ sales_channels list A list of sales
===================================== ========================== =======================================================
.. versionchanged:: 1.7
The ``meta_data`` field has been added.
.. versionchanged:: 1.15
The ``plugins`` field has been added.
The operations POST, PATCH, PUT and DELETE have been added.
.. versionchanged:: 2.1
Filters have been added to the list of events.
.. versionchanged:: 2.5
The ``testmode`` attribute has been added.
.. versionchanged:: 2.8
When cloning events, the ``testmode`` attribute will now be cloned, too.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
.. versionchanged:: 3.3
The attributes ``geo_lat`` and ``geo_lon`` have been added.

View File

@@ -46,24 +46,6 @@ internal_reference string Customer's refe
===================================== ========================== =======================================================
.. versionchanged:: 1.6
The attribute ``invoice_no`` has been dropped in favor of ``number`` which includes the number including the prefix,
since the prefix can now vary. Also, invoices now need to be identified by their ``number`` instead of the raw
number.
.. versionchanged:: 1.7
The attributes ``lines.tax_name``, ``foreign_currency_display``, ``foreign_currency_rate``, and
``foreign_currency_rate_date`` have been added.
.. versionchanged:: 1.9
The attribute ``internal_reference`` has been added.
.. versionchanged:: 3.4
The attribute ``lines.number`` has been added.

View File

@@ -28,10 +28,6 @@ multi_allowed boolean Adding the same
price_included boolean Adding this add-on to the item is free
===================================== ========================== =======================================================
.. versionchanged:: 1.12
This resource has been added.
Endpoints
---------

View File

@@ -30,10 +30,6 @@ designated_price money (string) Designated pric
taxation. This is not added to the price.
===================================== ========================== =======================================================
.. versionchanged:: 2.6
This resource has been added.
Endpoints
---------

View File

@@ -26,14 +26,6 @@ description multi-lingual string A public descri
position integer An integer, used for sorting
===================================== ========================== =======================================================
.. versionchanged:: 2.7
The attribute ``original_price`` has been added.
.. versionchanged:: 1.12
This resource has been added.
Endpoints
---------

View File

@@ -36,8 +36,8 @@ admission boolean ``true`` for it
(such as primary tickets) and ``false`` for others
(such as add-ons or merchandise).
position integer An integer, used for sorting
picture string A product picture to be displayed in the shop
(read-only, can be ``null``).
picture file A product picture to be displayed in the shop
(can be ``null``).
sales_channels list of strings Sales channels this product is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this item can be bought
@@ -118,44 +118,6 @@ bundles list of objects Definition of b
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== =======================================================
.. versionchanged:: 2.7
The attribute ``original_price`` has been added for ``variations``.
.. versionchanged:: 1.7
The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute
``checkin_attention`` has been added.
.. versionchanged:: 1.12
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
The attribute ``price_included`` has been added to ``addons``.
.. versionchanged:: 1.16
The ``internal_name`` and ``original_price`` fields have been added.
.. versionchanged:: 2.0
The field ``require_approval`` has been added.
.. versionchanged:: 2.3
The ``sales_channels`` attribute has been added.
.. versionchanged:: 2.4
The ``generate_tickets`` attribute has been added.
.. versionchanged:: 2.6
The ``bundles`` and ``require_bundling`` attributes have been added.
.. versionchanged:: 3.0
The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added.
.. versionchanged:: 3.7
The attribute ``meta_data`` has been added.

View File

@@ -94,60 +94,6 @@ last_modified datetime Last modificati
===================================== ========================== =======================================================
.. versionchanged:: 1.6
The ``invoice_address.country`` attribute contains a two-letter country code for all new orders. For old orders,
a custom text might still be returned.
.. versionchanged:: 1.7
The attributes ``invoice_address.vat_id_validated`` and ``invoice_address.is_business`` have been added.
The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate`` and ``order.payment_fee_tax_value`` have been
deprecated in favor of the new ``fees`` attribute but will still be served and removed in 1.9.
.. versionchanged:: 1.9
First write operations (``…/mark_paid/``, ``…/mark_pending/``, ``…/mark_canceled/``, ``…/mark_expired/``) have been added.
The attribute ``invoice_address.internal_reference`` has been added.
.. versionchanged:: 1.13
The field ``checkin_attention`` has been added.
.. versionchanged:: 1.15
The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate``, ``order.payment_fee_tax_value`` and
``order.payment_fee_tax_rule`` have finally been removed.
.. versionchanged:: 1.16
The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added.
An endpoint for order creation as well as ``…/mark_refunded/`` has been added.
.. versionchanged:: 2.0
The ``order.payment_date`` and ``order.payment_provider`` attributes have been deprecated in favor of the new
nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval``
attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints.
.. versionchanged:: 2.3
The ``sales_channel`` attribute has been added.
.. versionchanged:: 2.4
``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and
``…/mark_refunded/`` has been deprecated.
.. versionchanged:: 2.5
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
.. versionchanged:: 3.1
The ``invoice_address.state`` and ``url`` attributes have been added. When creating orders through the API,
vouchers are now supported and many fields are now optional.
.. versionchanged:: 3.5
The ``order.fees.canceled`` attribute has been added.
@@ -220,7 +166,7 @@ downloads list of objects List of ticket
└ url string Download URL
answers list of objects Answers to user-defined questions
├ question integer Internal ID of the answered question
├ answer string Text representation of the answer
├ answer string Text representation of the answer (URL if answer is a file)
├ 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
@@ -233,30 +179,6 @@ pdf_data object Data object req
``pdf_data=true`` query parameter to your request.
===================================== ========================== =======================================================
.. versionchanged:: 1.7
The attribute ``tax_rule`` has been added.
.. versionchanged:: 1.11
The attribute ``checkins.list`` has been added.
.. versionchanged:: 1.14
The attributes ``answers.question_identifier`` and ``answers.option_identifiers`` have been added.
.. versionchanged:: 1.16
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
.. versionchanged:: 3.0
The attribute ``seat`` has been added.
.. versionchanged:: 3.2
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
.. versionchanged:: 3.3
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
@@ -274,6 +196,10 @@ pdf_data object Data object req
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
@@ -302,14 +228,6 @@ details object Payment-specifi
the object is empty.
===================================== ========================== =======================================================
.. versionchanged:: 2.0
This resource has been added.
.. versionchanged:: 3.1
The attributes ``payment_url`` and ``details`` have been added.
.. _order-refund-resource:
Order refund resource
@@ -325,21 +243,14 @@ state string Payment state,
source string How this refund has been created, one of ``buyer``, ``admin``, or ``external``
amount money (string) Payment amount
created datetime Date and time of creation of this payment
payment_date datetime Date and time of completion of this payment (or ``null``)
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
===================================== ========================== =======================================================
.. versionchanged:: 2.0
This resource has been added.
List of all orders
------------------
.. versionchanged:: 1.15
Filtering for emails or order codes is now case-insensitive.
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
@@ -1445,21 +1356,6 @@ Sending e-mails
List of all order positions
---------------------------
.. versionchanged:: 1.15
The order positions endpoint has been extended by the filter queries ``item__in``, ``variation__in``,
``order__status__in``, ``subevent__in``, ``addon_to__in`` and ``search``. The search for attendee names and order
codes is now case-insensitive.
.. versionchanged:: 2.0
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
``pseudonymization_id``.
.. versionchanged:: 3.2
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
@@ -1706,6 +1602,67 @@ Order position ticket download
Manipulating individual positions
---------------------------------
.. versionchanged:: 3.15
The ``PATCH`` method has been added for individual positions.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Updates specific fields on an order position. Currently, only the following fields are supported:
* ``attendee_email``
* ``attendee_name_parts`` or ``attendee_name``
* ``company``
* ``street``
* ``zipcode``
* ``city``
* ``country``
* ``state``
* ``answers``: If specified, you will need to provide **all** answers for this order position.
Validation is handled the same way as when creating orders through the API. You are therefore
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
and ``option_identifiers`` will be ignored.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"attendee_email": "other@example.org"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param id: The ``id`` field of the order position to update
:statuscode 200: no error
:statuscode 400: The order could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Deletes an order position, identified by its internal ID.
@@ -1738,10 +1695,6 @@ Manipulating individual positions
Order payment endpoints
-----------------------
.. versionchanged:: 2.0
These endpoints have been added.
.. versionchanged:: 3.6
Payments can now be created through the API.
@@ -2021,10 +1974,6 @@ Order payment endpoints
Order refund endpoints
----------------------
.. versionchanged:: 2.0
These endpoints have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/
Returns a list of all refunds for an order.
@@ -2058,6 +2007,7 @@ Order refund endpoints
"payment": 1,
"created": "2017-12-01T10:00:00Z",
"execution_date": "2017-12-04T12:13:12Z",
"comment": "Cancellation",
"provider": "banktransfer"
}
]
@@ -2100,6 +2050,7 @@ Order refund endpoints
"payment": 1,
"created": "2017-12-01T10:00:00Z",
"execution_date": "2017-12-04T12:13:12Z",
"comment": "Cancellation",
"provider": "banktransfer"
}
@@ -2134,6 +2085,7 @@ Order refund endpoints
"amount": "23.00",
"payment": 1,
"execution_date": null,
"comment": "Cancellation",
"provider": "manual",
"mark_canceled": false,
"mark_pending": true
@@ -2155,6 +2107,7 @@ Order refund endpoints
"payment": 1,
"created": "2017-12-01T10:00:00Z",
"execution_date": null,
"comment": "Cancellation",
"provider": "manual"
}

View File

@@ -19,10 +19,6 @@ identifier string An arbitrary st
answer multi-lingual string The displayed value of this option
===================================== ========================== =======================================================
.. versionchanged:: 1.12
This resource has been added.
Endpoints
---------

View File

@@ -75,28 +75,6 @@ dependency_value string An old version
for one value. **Deprecated.**
===================================== ========================== =======================================================
.. versionchanged:: 1.12
The values ``D``, ``H``, and ``W`` for the field ``type`` are now allowed and the ``ask_during_checkin`` field has
been added.
.. versionchanged:: 1.14
Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the
options resource. The ``position`` attribute has been added to the options resource.
.. versionchanged:: 2.7
The attribute ``hidden`` and the question type ``CC`` have been added.
.. versionchanged:: 3.0
The attribute ``dependency_values`` has been added.
.. versionchanged:: 3.1
The attribute ``print_on_invoice`` has been added.
.. versionchanged:: 3.5
The attribute ``help_text`` has been added.

View File

@@ -30,14 +30,6 @@ release_after_exit boolean Whether the quo
have been scanned at an exit.
===================================== ========================== =======================================================
.. versionchanged:: 1.10
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attributes ``close_when_sold_out`` and ``closed`` have been added.
.. versionchanged:: 3.10
The attribute ``release_after_exit`` has been added.

View File

@@ -20,10 +20,6 @@ layout object JSON representa
still evolves. The version in use can be found `here`_.
===================================== ========================== =======================================================
.. versionchanged:: 3.0
This endpoint has been added.
Endpoints
---------

View File

@@ -33,6 +33,7 @@ date_to datetime The sub-event's
date_admission datetime The sub-event's admission date (or ``null``)
presale_start datetime The sub-date at which the ticket shop opens (or ``null``)
presale_end datetime The sub-date at which the ticket shop closes (or ``null``)
frontpage_text multi-lingual string The description of the event (or ``null``)
location multi-lingual string The sub-event location (or ``null``)
geo_lat float Latitude of the location (or ``null``)
geo_lon float Longitude of the location (or ``null``)
@@ -54,25 +55,6 @@ seat_category_mapping object An object mappi
last_modified datetime Last modification of this object
===================================== ========================== =======================================================
.. versionchanged:: 1.7
The ``meta_data`` field has been added.
.. versionchanged:: 2.1
The ``event`` field has been added, together with filters on the list of dates and an organizer-level list.
.. versionchanged:: 2.6
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 2.7
The attribute ``is_public`` has been added.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
.. versionchanged:: 3.3
The attributes ``geo_lat`` and ``geo_lon`` have been added.

View File

@@ -24,14 +24,6 @@ home_country string Merchant countr
``null`` or empty string
===================================== ========================== =======================================================
.. versionchanged:: 1.7
This resource has been added.
.. versionchanged:: 1.9
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
Endpoints
---------

View File

@@ -46,14 +46,6 @@ show_hidden_items boolean Only if set to
===================================== ========================== =======================================================
.. versionchanged:: 1.9
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attribute ``show_hidden_items`` has been added.
.. versionchanged:: 3.4
The attribute ``seat`` has been added.

View File

@@ -14,7 +14,9 @@ Control panel views
-------------------
If you want to add a custom view to the control area of an event, just register an URL in your
``urls.py`` that lives in the ``/control/`` subpath::
``urls.py`` that lives in the ``/control/`` subpath:
.. code-block:: python
from django.conf.urls import url
@@ -44,7 +46,9 @@ If only the ``organizer`` parameter is present, it will be ensured that:
* The user has permission to access view the current organizer
If you want to require specific permission types, we provide you with a decorator or a mixin for
your views::
your views:
.. code-block:: python
from pretix.control.permissions import (
event_permission_required, EventPermissionRequiredMixin
@@ -61,8 +65,9 @@ your views::
...
Similarly, there is ``organizer_permission_required`` and ``OrganizerPermissionRequiredMixin``. In case of
event-related views, there is also a signal that allows you to add the view to the event navigation like this::
event-related views, there is also a signal that allows you to add the view to the event navigation like this:
.. code-block:: python
from django.urls import resolve, reverse
from django.dispatch import receiver
@@ -90,7 +95,9 @@ Event settings view
-------------------
A special case of a control panel view is a view hooked into the event settings page. For this case, there is a
special navigation signal::
special navigation signal:
.. code-block:: python
@receiver(nav_event_settings, dispatch_uid='friends_tickets_nav_settings')
def navbar_settings(sender, request, **kwargs):
@@ -105,7 +112,9 @@ special navigation signal::
}]
Also, your view should inherit from ``EventSettingsViewMixin`` and your template from ``pretixcontrol/event/settings_base.html``
for good integration. If you just want to display a form, you could do it like the following::
for good integration. If you just want to display a form, you could do it like the following:
.. code-block:: python
class MySettingsView(EventSettingsViewMixin, EventSettingsFormView):
model = Event
@@ -147,7 +156,9 @@ Including a custom view into the participant-facing frontend is a little bit dif
no path prefix like ``control/``.
First, define your URL in your ``urls.py``, but this time in the ``event_patterns`` section and wrapped by
``event_url``::
``event_url``:
.. code-block:: python
from pretix.multidomain import event_url
@@ -182,8 +193,9 @@ standard Django request handling: There are `ViewSets`_ to group related views i
automatically build URL configurations from them.
To integrate a custom viewset with pretix' REST API, you can just register with one of our routers within the
``urls.py`` module of your plugin::
``urls.py`` module of your plugin:
.. code-block:: python
from pretix.api.urls import event_router, router, orga_router
@@ -200,7 +212,9 @@ in the control panel. However, you need to make sure on your own only to return
.event`` and ``request.organizer`` are available as usual.
To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base
class, you can just set the ``permission`` attribute on your viewset::
class, you can just set the ``permission`` attribute on your viewset:
.. code-block:: python
class MyViewSet(ModelViewSet):
permission = 'can_view_orders'
@@ -208,8 +222,9 @@ class, you can just set the ``permission`` attribute on your viewset::
If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that
API authentications can be done via user sessions or API tokens and you should therefore check something like the
following::
following:
.. code-block:: python
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'):

View File

@@ -15,7 +15,9 @@ Output registration
The email HTML renderer API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available email renderers. Your plugin
should listen for this signal and return the subclass of ``pretix.base.email.BaseHTMLMailRenderer``
that we'll provide in this plugin::
that we'll provide in this plugin:
.. code-block:: python
from django.dispatch import receiver
@@ -72,7 +74,9 @@ class ``TemplateBasedMailRenderer`` that you can re-use to perform the following
* Call `inlinestyler`_ to convert all ``<style>`` style sheets to inline ``style=""``
attributes for better compatibility
To use it, you just need to implement some variables::
To use it, you just need to implement some variables:
.. code-block:: python
class ClassicMailRenderer(TemplateBasedMailRenderer):
verbose_name = _('pretix default')

View File

@@ -17,7 +17,9 @@ Exporter registration
The exporter API does not make a lot of usage from signals, however, it does use a signal to get a list of
all available exporters. Your plugin should listen for this signal and return the subclass of
``pretix.base.exporter.BaseExporter``
that we'll provide in this plugin::
that we'll provide in this plugin:
.. code-block:: python
from django.dispatch import receiver
@@ -31,7 +33,9 @@ that we'll provide in this plugin::
Some exporters might also prove to be useful, when provided on an organizer-level. In order to declare your
exporter as capable of providing exports spanning multiple events, your plugin should listen for this signal
and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin::
and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin:
.. code-block:: python
from django.dispatch import receiver

View File

@@ -34,7 +34,7 @@ Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
.. automodule:: pretix.presale.signals
@@ -79,7 +79,7 @@ Ticket designs
""""""""""""""
.. automodule:: pretix.base.signals
:members: layout_text_variables
:members: layout_text_variables, layout_image_variables
.. automodule:: pretix.plugins.ticketoutputpdf.signals
:members: override_layout

View File

@@ -15,7 +15,9 @@ Output registration
The invoice renderer API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available invoice renderers. Your plugin
should listen for this signal and return the subclass of ``pretix.base.invoice.BaseInvoiceRenderer``
that we'll provide in this plugin::
that we'll provide in this plugin:
.. code-block:: python
from django.dispatch import receiver

View File

@@ -19,7 +19,9 @@ Provider registration
The payment provider API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available payment providers. Your plugin
should listen for this signal and return the subclass of ``pretix.base.payment.BasePaymentProvider``
that the plugin will provide::
that the plugin will provide:
.. code-block:: python
from django.dispatch import receiver
@@ -104,14 +106,22 @@ The provider class
.. automethod:: payment_control_render
.. automethod:: payment_control_render_short
.. automethod:: payment_refund_supported
.. automethod:: payment_partial_refund_supported
.. automethod:: payment_presale_render
.. automethod:: execute_refund
.. automethod:: refund_control_render
.. automethod:: new_refund_control_form_render
.. automethod:: new_refund_control_form_process
.. automethod:: api_payment_details
.. automethod:: matching_id
@@ -140,7 +150,9 @@ it is necessary to introduce additional views. One example is the PayPal
provider. It redirects the user to a PayPal website in the
:py:meth:`BasePaymentProvider.checkout_prepare` step of the checkout process
and provides PayPal with a URL to redirect back to. This URL points to a
view which looks roughly like this::
view which looks roughly like this:
.. code-block:: python
@login_required
def success(request):

View File

@@ -13,7 +13,9 @@ Placeholder registration
The placeholder API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available email placeholders. Your plugin
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``::
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``:
.. code-block:: python
from django.dispatch import receiver
@@ -71,7 +73,9 @@ Helper class for simple placeholders
------------------------------------
pretix ships with a helper class that makes it easy to provide placeholders based on simple
functions::
functions:
.. code-block:: python
placeholder = SimpleFunctionalMailTextPlaceholder(
'code', ['order'], lambda order: order.code, sample='F8VVL'

View File

@@ -55,7 +55,9 @@ restricted boolean (optional) ``False`` by default, restricts a plugin
compatibility string Specifier for compatible pretix versions.
================== ==================== ===========================================================
A working example would be::
A working example would be:
.. code-block:: python
try:
from pretix.base.plugins import PluginConfig
@@ -81,7 +83,7 @@ A working example would be::
default_app_config = 'pretix_paypal.PaypalApp'
The ``AppConfig`` class may implement a property ``compatiblity_errors``, that checks
The ``AppConfig`` class may implement a property ``compatibility_errors``, that checks
whether the pretix installation meets all requirements of the plugin. If so,
it should contain ``None`` or an empty list, otherwise a list of strings containing
human-readable error messages. We recommend using the ``django.utils.functional.cached_property``
@@ -96,7 +98,9 @@ Plugin registration
Somehow, pretix needs to know that your plugin exists at all. For this purpose, we
make use of the `entry point`_ feature of setuptools. To register a plugin that lives
in a separate python package, your ``setup.py`` should contain something like this::
in a separate python package, your ``setup.py`` should contain something like this:
.. code-block:: python
setup(
args...,
@@ -118,7 +122,9 @@ The various components of pretix define a number of signals which your plugin ca
listen for. We will go into the details of the different signals in the following
pages. We suggest that you put your signal receivers into a ``signals`` submodule
of your plugin. You should extend your ``AppConfig`` (see above) by the following
method to make your receivers available::
method to make your receivers available:
.. code-block:: python
class PaypalApp(AppConfig):
@@ -127,7 +133,9 @@ method to make your receivers available::
from . import signals # NOQA
You can optionally specify code that is executed when your plugin is activated for an event
in the ``installed`` method::
in the ``installed`` method:
.. code-block:: python
class PaypalApp(AppConfig):

View File

@@ -74,7 +74,7 @@ looks like this:
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'invoice-addresses.json', 'application/json', json.dumps({
ia.order.code: InvoiceAdddressSerializer(ia).data
ia.order.code: InvoiceAddressSerializer(ia).data
for ia in InvoiceAddress.objects.filter(order__event=self.event)
}, indent=4)

View File

@@ -17,7 +17,9 @@ Output registration
The ticket output API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available ticket outputs. Your plugin
should listen for this signal and return the subclass of ``pretix.base.ticketoutput.BaseTicketOutput``
that we'll provide in this plugin::
that we'll provide in this plugin:
.. code-block:: python
from django.dispatch import receiver

View File

@@ -82,11 +82,15 @@ Orders
^^^^^^
If a customer completes the checkout process, an **Order** will be created containing all the entered information.
An order can be in one of currently four states that are listed in the diagram below:
An order can be in one of currently six states that are listed in the diagram below:
.. image:: /images/order_states.png
There are additional "fake" states that are displayed like states but not represented as states in the system:
The dotted lines represent status changes that usually do not happen as part of the regular process, but can be
performed manually in the admin backend.
For historical reasons, there are only four valid values of the ``status`` field, and the two additional states are
represented differently:
* An order is considered **canceled (with paid fee)** if it is in **paid** status but does not include any non-cancelled positions.

View File

@@ -12,7 +12,9 @@ Implementing a task
-------------------
A common pattern for implementing asynchronous tasks can be seen a lot in ``pretix.base.services``
and looks like this::
and looks like this:
.. code-block:: python
from pretix.celery_app import app
@@ -34,13 +36,15 @@ If your user needs to wait for the response of the asynchronous task, there are
that will probably move to ``pretix.base`` at some point. They consist of the view mixin ``AsyncAction`` that allows
you to easily write a view that kicks off and waits for an asynchronous task. ``AsyncAction`` will determine whether
to run the task asynchronously or not and will do some magic to look nice for users with and without JavaScript support.
A usage example taken directly from the code is::
A usage example taken directly from the code is:
.. code-block:: python
class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
"""
A view that executes a task asynchronously. A POST request will kick off the
task into the background or run it in the foreground if celery is not installed.
In the former case, subsequent GET calls can be used to determinine the current
In the former case, subsequent GET calls can be used to determine the current
status of the task.
"""
@@ -79,7 +83,9 @@ A usage example taken directly from the code is::
return super().get_error_message(exception)
On the client side, this can be used by simply adding a ``data-asynctask`` attribute to an HTML form. This will enable
AJAX sending of the form and display a loading indicator::
AJAX sending of the form and display a loading indicator:
.. code-block:: html
<form method="post" data-asynctask
action="{% eventurl request.event "presale:event.order.cancel.do" %}">

View File

@@ -27,7 +27,9 @@ numbers and dates, ``LazyDate`` and ``LazyNumber``. There also is a ``LazyLocale
exceptions with gettext-localized exception messages.
Last, but definitely not least, we have the ``language`` context manager (``pretix.base.i18n.language``) that allows
you to execute a piece of code with a different locale::
you to execute a piece of code with a different locale:
.. code-block:: python
with language('de'):
render_mail_template()

View File

@@ -16,7 +16,9 @@ We recommend all relevant models to inherit from ``LoggedModel`` as it simplifie
.. autoclass:: pretix.base.models.LoggedModel
:members: log_action, all_logentries
To actually log an action, you can just call the ``log_action`` method on your object::
To actually log an action, you can just call the ``log_action`` method on your object:
.. code-block:: python
order.log_action('pretix.event.order.canceled', user=user, data={})
@@ -29,7 +31,9 @@ Logging form actions
""""""""""""""""""""
A very common use case is to log the changes to a model that have been done in a ``ModelForm``. In this case,
we generally use a custom ``form_valid`` method on our ``FormView`` that looks like this::
we generally use a custom ``form_valid`` method on our ``FormView`` that looks like this:
.. code-block:: python
@transaction.atomic
def form_valid(self, form):
@@ -40,7 +44,9 @@ we generally use a custom ``form_valid`` method on our ``FormView`` that looks l
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
It gets a little bit more complicated if your form allows file uploads::
It gets a little bit more complicated if your form allows file uploads:
.. code-block:: python
@transaction.atomic
def form_valid(self, form):
@@ -67,7 +73,9 @@ following ready-to-include template::
We now need a way to translate the action codes like ``pretix.event.changed`` into human-readable
strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple
implementation could look like::
implementation could look like:
.. code-block:: python
from django.utils.translation import gettext as _
from pretix.base.signals import logentry_display
@@ -88,7 +96,9 @@ Sending notifications
If you think that the logged information might be important or urgent enough to send out a notification to interested
organizers. In this case, you should listen for the :py:attr:`pretix.base.signals.register_notification_types` signal
to register a notification type::
to register a notification type:
.. code-block:: python
@receiver(register_notification_types)
def register_my_notification_types(sender, **kwargs):
@@ -103,7 +113,9 @@ You should subclass the base ``NotificationType`` class and implement all its me
.. autoclass:: pretix.base.notifications.NotificationType
:members: action_type, verbose_name, required_permission, build_notification
A simple implementation could look like this::
A simple implementation could look like this:
.. code-block:: python
class MyNotificationType(NotificationType):
required_permission = "can_view_orders"
@@ -143,7 +155,9 @@ Logging technical information
-----------------------------
If you just want to log technical information to a log file on disk that does not need to be parsed
and displayed later, you can just use Python's ``logging`` module::
and displayed later, you can just use Python's ``logging`` module:
.. code-block:: python
import logging
@@ -151,7 +165,9 @@ and displayed later, you can just use Python's ``logging`` module::
logger.info('Startup complete.')
This is also very useful to provide debugging information when an exception occurs::
This is also very useful to provide debugging information when an exception occurs:
.. code-block:: python
try:
foo()

View File

@@ -15,7 +15,9 @@ Requiring permissions for a view
--------------------------------
pretix provides a number of useful mixins and decorators that allow you to specify that a user needs a certain
permission level to access a view::
permission level to access a view:
.. code-block:: python
from pretix.control.permissions import (
OrganizerPermissionRequiredMixin, organizer_permission_required
@@ -44,7 +46,9 @@ permission level to access a view::
# Only users with *any* permission on this organizer can access this
Of course, the same is available on event level::
Of course, the same is available on event level:
.. code-block:: python
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required
@@ -73,7 +77,9 @@ Of course, the same is available on event level::
# Only users with *any* permission on this event can access this
You can also require that this view is only accessible by system administrators with an active "admin session"
(see below for what this means)::
(see below for what this means):
.. code-block:: python
from pretix.control.permissions import (
AdministratorPermissionRequiredMixin, administrator_permission_required
@@ -89,7 +95,9 @@ You can also require that this view is only accessible by system administrators
# ...
In rare cases it might also be useful to expose a feature only to people who have a staff account but do not
necessarily have an active admin session::
necessarily have an active admin session:
.. code-block:: python
from pretix.control.permissions import (
StaffMemberRequiredMixin, staff_member_required

View File

@@ -39,7 +39,9 @@ subclass that also adds support for internationalized fields:
.. autoclass:: pretix.base.forms.SettingsForm
You can simply use it like this::
You can simply use it like this:
.. code-block:: python
class EventSettingsForm(SettingsForm):
show_date_to = forms.BooleanField(
@@ -56,7 +58,9 @@ You can simply use it like this::
Defaults in plugins
-------------------
Plugins can add custom hardcoded defaults in the following way::
Plugins can add custom hardcoded defaults in the following way:
.. code-block:: python
from pretix.base.settings import settings_hierarkey

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,34 @@
@startuml
participant User
collections "OrderPayment\nOrderRefund" as P
collections "Order\nOrderPosition" as O
collections "Invoice\nInvoiceLine" as I
User -> O: Order placed (€100)
rnote over O #6DD96D: Order A1B2C\nstatus = **n**\ntotal = €100
O -> P: Payment created
O -> I: Invoice created\n(can also happen later)
rnote over I #6DD96D: Invoice 00001\n€100
rnote over P #6DD96D: OrderPayment A1B2C-P-1\nstate = **created**
P -> User: Payment details (web, email)
User -> P: Payment performed
rnote over P #EFF46B: OrderPayment A1B2C-P-1\nstate = **confirmed**
P -> O: Order marked as paid
rnote over O #EFF46B: Order A1B2C\nstatus = **p**\ntotal = €100
User -> O: Data change (e.g. invoice address)
O -> I: Invoice reissued
rnote over I #6DD96D: Invoice 00002\n€-100
rnote over I #6DD96D: Invoice 00003\n€100
rnote over O #EFF46B: Order A1B2C\nstatus = **p**\ntotal = €100
User -> O: Order canceled
rnote over O #EFF46B: Order A1B2C\nstatus = **c**
O -> I: Invoice canceled
rnote over I #6DD96D: Invoice 00004\n€-100
O -> P: Refund started
rnote over P #6DD96D: OrderRefund\nA1B2C-R-1\nstate = **created**
P -> User: Money sent
rnote over P #EFF46B: OrderRefund\nA1B2C-R-1\nstate = **done**
@enduml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1,19 +1,39 @@
@startuml
Pending: Order is expecting payment\nOrder reduces quotas
Expired: Payment period is over\nOrder does not affect quotas
Paid: Order was successful\nOrder reduces quotas
Canceled: Order has been canceled\nOrder does not affect quotas
state "Approval Pending" as AP
state "Canceled (with paid fee)" as CP
AP: status = "n"
AP: require_approval = true
Pending: status = "n"
Pending: require_approval = false
Pending: Tickets reserved: yes
Expired: status = "e"
Expired: Tickets reserved: no
Paid: status = "p"
Paid: count(positions | !canceled) > 0
Paid: Tickets reserved: yes
CP: status = "p"
CP: count(positions | !canceled) = 0
Canceled: status = "c"
Canceled: Tickets reserved: no
[*] --> Pending: customer\nplaces order
Pending --> Paid: successful payment
Pending --> Expired: automatically\nor manually\non admin action
Expired --> Paid: if payment is received\nonly if quota left
Expired --> Canceled
Expired --> Pending: manually\non admin action
Paid --> Canceled: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
Pending --> Canceled: on admin or\ncustomer action
Paid -> Pending: manually on admin action
[*] --> Paid: customer\nplaces free order
[*] -> Pending: order placed\ntotal > 0
[*] -> Paid: order placed\ntotal = 0
[*] -> AP: order placed\napproval required
Pending --> Paid: order paid
Pending --> Expired: after payment\ndeadline
Expired --> Paid: order paid\n(only if quota left)
Expired -[dashed]-> Canceled
Expired -[dashed]-> Pending: order extended
Paid --> Canceled: order canceled
Pending --> Canceled: order canceled
Paid -[dashed]-> Pending: refund
AP --> Pending: order approved
AP --> Canceled: order denied
Paid --> CP: order canceled\n(with cancellation fee)
Canceled -[dashed]-> Pending: order reactivated
Canceled -[dashed]-> Paid: order reactivated
CP -[dashed]-> Canceled: fee canceled
@enduml

View File

@@ -22,10 +22,6 @@ item_assignments list of objects Products this l
└ item integer Item ID
===================================== ========================== =======================================================
.. versionchanged:: 1.16
This resource has been added.
Endpoints
---------

View File

@@ -24,14 +24,6 @@ item_assignments list of objects Products this l
└ item integer Item ID
===================================== ========================== =======================================================
.. versionchanged:: 1.16
This resource has been added.
.. versionchanged:: 2.3
The ``item_assignments.sales_channel`` field has been added.
Endpoints
---------

View File

@@ -64,20 +64,35 @@ is valid in every text):
Placeholder Description
============================== ===============================================================================
event The event name
event_slug The event's short form
code In case of the waiting list, the voucher code to redeem
currency The currency used for the event (three-letter code)
total The order's total value
total_with_currency The order's total value with a localized currency sign
currency The currency used for the event (three-letter code)
refund_amount (For cancellation emails) The amount of money that will be refunded, including
the currency
payment_info Information text specific to the payment method (e.g. banking details)
url An URL pointing to the download/status page of the order
invoice_name The name field of the invoice address
url_info_change An URL pointing to the page of the order that can be used to change ticket
information
url_products_change An URL pointing to the page of the order that can be used to change the products
in the order
url_cancel An URL pointing to the page of the order that can be used to cancel the order
name, name_* Any name that can be used to address the recipient (e.g. name from invoice address,
name from first ticket, …)
invoice_name, invoice_name_* The name field of the invoice address
invoice_company The company field of the invoice address
attendee_name, attendee_name_* The name of the attendee represented by the ticket
expire_date The order's expiration date
comment When rejecting an order, this will contain the reason for the rejection
date The same as ``expire_date``, but in a different e-mail (for backwards
compatibility)
orders A list of orders including links to their status pages, specific to the "resend
link (requested by user)" e-mail
code In case of the waiting list, the voucher code to redeem
hours In case of the waiting list, the number of hours the voucher code is valid
product In case of the waiting list, the product that has become available
voucher_list When sending out vouchers in bulk, this will be replaced with the list of
vouchers
============================== ===============================================================================
The different e-mails are explained in the following:

View File

@@ -88,6 +88,15 @@ website. If you confident to have a good reason for not using SSL, you can overr
<pretix-widget event="https://pretix.eu/demo/democon/" skip-ssl-check></pretix-widget>
Always open a new tab
---------------------
If you want the checkout process to always open a new tab regardless of screen size, you can pass the ``disable-iframe``
attribute::
<pretix-widget event="https://pretix.eu/demo/democon/" disable-iframe></pretix-widget>
Pre-selecting a voucher
-----------------------
@@ -197,7 +206,10 @@ should be added to the cart. The syntax of this attribute is ``item_ITEMID=1,ite
where ``ITEMID`` are the internal IDs of items to be added and ``VARID`` are the internal IDs of variations of those
items, if the items have variations. If you omit the ``items`` attribute, the general start page will be presented.
Just as the widget, the button supports the optional attributes ``voucher`` and ``skip-ssl-check``.
In case you are using an event-series, you will need to specify the subevent for which the item(s) should be put in the
cart. This can be done by specifying the ``subevent``-attribute.
Just as the widget, the button supports the optional attributes ``voucher``, ``disable-iframe``, and ``skip-ssl-check``.
You can style the button using the ``pretix-button`` CSS class.
@@ -304,8 +316,92 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
made through this widget will be counted towards this campaign.
* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
require you to dynamically load the widget, like this::
* If you use the tracking plugin, you can enable cross-domain tracking. To do so, you need to initialize the
pretix-widget manually. Use the html code to embed the widget and add one the following code snippets. Make sure to
replace all occurrences of <MEASUREMENT_ID> with your Google Analytics MEASUREMENT_ID (UA-XXXXXXX-X or G-XXXXXXXX)
Please also make sure to add the embedding website to your `Referral exclusions
<https://support.google.com/analytics/answer/2795830>`_ in your Google Analytics settings.
If you use Google Analytics 4 (GA4 G-XXXXXXXX)::
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
<script type="text/javascript">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '<MEASUREMENT_ID>');
window.pretixWidgetCallback = function () {
window.PretixWidget.build_widgets = false;
window.addEventListener('load', function() { // Wait for GA to be loaded
if (!window['google_tag_manager']) {
window.PretixWidget.buildWidgets();
return;
}
var clientId;
var sessionId;
var loadingTimeout;
function build() {
// use loadingTimeout to make sure build() is only called once
if (!loadingTimeout) return;
window.clearTimeout(loadingTimeout);
loadingTimeout = null;
if (clientId) window.PretixWidget.widget_data["tracking-ga-id"] = clientId;
if (sessionId) window.PretixWidget.widget_data["tracking-ga-sessid"] = sessionId;
window.PretixWidget.buildWidgets();
};
// make sure to build pretix-widgets if gtag fails to load either client_id or session_id
loadingTimeout = window.setTimeout(build, 2000);
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
clientId = id;
if (sessionId !== undefined) build();
});
gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) {
sessionId = id;
if (clientId !== undefined) build();
});
});
};
</script>
If you use Universal Analytics with ``gtag.js`` (UA-XXXXXXX-X)::
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
<script type="text/javascript">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '<MEASUREMENT_ID>');
window.pretixWidgetCallback = function () {
window.PretixWidget.build_widgets = false;
window.addEventListener('load', function() { // Wait for GA to be loaded
if (!window['google_tag_manager']) {
window.PretixWidget.buildWidgets();
return;
}
// make sure to build pretix-widgets if gtag fails to load client_id
var loadingTimeout = window.setTimeout(function() {
loadingTimeout = null;
window.PretixWidget.buildWidgets();
}, 1000);
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
if (loadingTimeout) {
window.clearTimeout(loadingTimeout);
window.PretixWidget.widget_data["tracking-ga-id"] = id;
window.PretixWidget.buildWidgets();
}
});
});
};
</script>
If you use ```analytics.js` (Universal Analytics)::
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
@@ -313,32 +409,33 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXXX-1', 'auto');
ga('create', '<MEASUREMENT_ID>', 'auto');
ga('send', 'pageview');
window.pretixWidgetCallback = function () {
window.PretixWidget.build_widgets = false;
window.addEventListener('load', function() { // Wait for GA to be loaded
if(window.ga && ga.create) {
ga(function(tracker) {
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
window.PretixWidget.buildWidgets()
});
} else { // Tracking is probably blocked
window.PretixWidget.buildWidgets()
if (!window['ga'] || !ga.create) {
// Tracking is probably blocked
window.PretixWidget.buildWidgets()
return;
}
var loadingTimeout = window.setTimeout(function() {
loadingTimeout = null;
window.PretixWidget.buildWidgets();
}, 1000);
ga(function(tracker) {
if (loadingTimeout) {
window.clearTimeout(loadingTimeout);
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
window.PretixWidget.buildWidgets();
}
});
});
};
</script>
In some combinations with Google Tag Manager, the widget does not load this way. In this case, try replacing
``tracker.get('clientId')`` with ``ga.getAll()[0].get('clientId')``.
.. versionchanged:: 2.3
Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work
fully if you configured a redis server.
.. versionchanged:: 3.6

View File

@@ -1 +1 @@
__version__ = "3.14.0"
__version__ = "3.17.0.dev0"

View File

@@ -41,7 +41,9 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:order-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
)
@@ -67,7 +69,9 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlist-status'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
)
@@ -95,7 +99,9 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:order-list'),
('GET', 'api-v1:order-detail'),
('DELETE', 'api-v1:orderposition-detail'),
('GET', 'api-v1:orderposition-pdf_image'),
('POST', 'api-v1:order-mark_canceled'),
('POST', 'api-v1:orderpayment-list'),
('POST', 'api-v1:orderrefund-list'),
('POST', 'api-v1:orderrefund-done'),
('POST', 'api-v1:cartposition-list'),
@@ -113,6 +119,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'plugins:pretix_seating:event.event.subevent'),
('GET', 'plugins:pretix_seating:event.plan'),
('GET', 'plugins:pretix_seating:selection.simple'),
('POST', 'api-v1:upload'),
)

View File

@@ -89,10 +89,38 @@ class EventCRUDPermission(EventPermission):
class ProfilePermission(BasePermission):
def has_permission(self, request, view):
if not request.user.is_authenticated:
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
return False
if request.user.is_authenticated:
try:
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
assert_session_valid(request)
except SessionInvalid:
return False
except SessionReauthRequired:
return False
if isinstance(request.auth, OAuthAccessToken):
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
return False
return True
class AnyAuthenticatedClientPermission(BasePermission):
def has_permission(self, request, view):
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
return False
if request.user.is_authenticated:
try:
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
assert_session_valid(request)
except SessionInvalid:
return False
except SessionReauthRequired:
return False
return True

View File

@@ -1,5 +1,6 @@
from datetime import timedelta
from django.core.files import File
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
@@ -100,8 +101,15 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
for answ_data in answers_data:
options = answ_data.pop('options')
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = cp.answers.create(**answ_data, answer='')
answ.file.save(an.name, an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
else:
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
return cp
def validate_cart_id(self, cid):

View File

@@ -1,25 +1,29 @@
import logging
from django.conf import settings
from django.core.exceptions import ValidationError
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 django_countries.serializers import CountryFieldMixin
from hierarkey.proxy import HierarkeyProxy
from pytz import common_timezones
from rest_framework import serializers
from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Event, TaxRule
from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.models import Device, Event, TaxRule, TeamAPIToken
from pretix.base.models.event import SubEvent
from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
from pretix.base.settings import DEFAULTS, validate_event_settings
from pretix.base.settings import validate_event_settings
from pretix.base.signals import api_event_settings_fields
logger = logging.getLogger(__name__)
class MetaDataField(Field):
@@ -170,9 +174,12 @@ class EventSerializer(I18nAwareModelSerializer):
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
for key, v in value['meta_data'].items():
if key not in self.meta_properties:
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
if self.meta_properties[key].allowed_values:
if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]:
raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v))
return value
@cached_property
@@ -219,6 +226,14 @@ class EventSerializer(I18nAwareModelSerializer):
return value
@cached_property
def ignored_meta_properties(self):
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
else self.context['request'].user)
if perm_holder.has_organizer_permission('can_change_organizer_settings', request=self.context['request']):
return []
return [k for k, p in self.meta_properties.items() if p.protected]
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
@@ -234,10 +249,11 @@ class EventSerializer(I18nAwareModelSerializer):
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
event.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
if key not in self.ignored_meta_properties:
event.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
# Item Meta properties
if item_meta_properties is not None:
@@ -275,19 +291,21 @@ class EventSerializer(I18nAwareModelSerializer):
if meta_data is not None:
current = {mv.property: mv for mv in event.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
event.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
if key not in self.ignored_meta_properties:
prop = self.meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
event.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
if prop.name not in self.ignored_meta_properties:
if prop.name not in meta_data:
current_object.delete()
# Item Meta properties
if item_meta_properties is not None:
@@ -391,8 +409,8 @@ class SubEventSerializer(I18nAwareModelSerializer):
model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
'seating_plan', 'item_price_overrides', 'variation_price_overrides', 'meta_data',
'seat_category_mapping', 'last_modified')
'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides',
'meta_data', 'seat_category_mapping', 'last_modified')
def validate(self, data):
data = super().validate(data)
@@ -440,11 +458,22 @@ class SubEventSerializer(I18nAwareModelSerializer):
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
for key, v in value['meta_data'].items():
if key not in self.meta_properties:
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
if self.meta_properties[key].allowed_values:
if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]:
raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v))
return value
@cached_property
def ignored_meta_properties(self):
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
else self.context['request'].user)
if perm_holder.has_organizer_permission('can_change_organizer_settings', request=self.context['request']):
return []
return [k for k, p in self.meta_properties.items() if p.protected]
@transaction.atomic
def create(self, validated_data):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
@@ -461,10 +490,11 @@ class SubEventSerializer(I18nAwareModelSerializer):
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
subevent.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
if key not in self.ignored_meta_properties:
subevent.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
# Seats
if subevent.seating_plan:
@@ -510,19 +540,21 @@ class SubEventSerializer(I18nAwareModelSerializer):
if meta_data is not None:
current = {mv.property: mv for mv in subevent.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
subevent.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
if key not in self.ignored_meta_properties:
prop = self.meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
subevent.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
if prop.name not in self.ignored_meta_properties:
if prop.name not in meta_data:
current_object.delete()
# Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
@@ -558,12 +590,13 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
class EventSettingsSerializer(serializers.Serializer):
class EventSettingsSerializer(SettingsSerializer):
default_fields = [
'imprint_url',
'checkout_email_helptext',
'presale_has_ended_text',
'voucher_explanation_text',
'checkout_success_text',
'banner_text',
'banner_text_bottom',
'show_dates_on_frontpage',
@@ -576,6 +609,7 @@ class EventSettingsSerializer(serializers.Serializer):
'locale',
'region',
'last_order_modification_date',
'allow_modifications_after_checkin',
'show_quota_left',
'waiting_list_enabled',
'waiting_list_hours',
@@ -623,6 +657,7 @@ class EventSettingsSerializer(serializers.Serializer):
'mail_from',
'mail_from_name',
'mail_attach_ical',
'mail_attach_tickets',
'invoice_address_asked',
'invoice_address_required',
'invoice_address_vatid',
@@ -654,6 +689,7 @@ class EventSettingsSerializer(serializers.Serializer):
'invoice_additional_text',
'invoice_footer_text',
'invoice_eu_currencies',
'invoice_logo_image',
'cancel_allow_user',
'cancel_allow_user_until',
'cancel_allow_user_paid',
@@ -663,6 +699,7 @@ class EventSettingsSerializer(serializers.Serializer):
'cancel_allow_user_paid_keep_percentage',
'cancel_allow_user_paid_adjust_fees',
'cancel_allow_user_paid_adjust_fees_explanation',
'cancel_allow_user_paid_adjust_fees_step',
'cancel_allow_user_paid_refund_as_giftcard',
'cancel_allow_user_paid_require_approval',
'change_allow_user_variation',
@@ -674,45 +711,21 @@ class EventSettingsSerializer(serializers.Serializer):
'theme_color_background',
'theme_round_borders',
'primary_font',
'logo_image',
'logo_image_large',
'logo_show_title',
'og_image',
]
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
self.changed_data = []
super().__init__(*args, **kwargs)
for fname in self.default_fields:
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
if callable(kwargs):
kwargs = kwargs()
kwargs.setdefault('required', False)
kwargs.setdefault('allow_null', True)
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
if callable(form_kwargs):
form_kwargs = form_kwargs()
if 'serializer_class' not in DEFAULTS[fname]:
raise ValidationError('{} has no serializer class'.format(fname))
f = DEFAULTS[fname]['serializer_class'](
**kwargs
)
f._label = form_kwargs.get('label', fname)
f._help_text = form_kwargs.get('help_text')
self.fields[fname] = f
for recv, resp in api_event_settings_fields.send(sender=self.event):
for fname, field in resp.items():
field.required = False
self.fields[fname] = field
def update(self, instance: HierarkeyProxy, validated_data):
for attr, value in validated_data.items():
if value is None:
instance.delete(attr)
self.changed_data.append(attr)
elif instance.get(attr, as_type=type(value)) != value:
instance.set(attr, value)
self.changed_data.append(attr)
return instance
def validate(self, data):
data = super().validate(data)
settings_dict = self.instance.freeze()
@@ -720,6 +733,14 @@ class EventSettingsSerializer(serializers.Serializer):
validate_event_settings(self.event, settings_dict)
return data
def get_new_filename(self, name: str) -> str:
nonce = get_random_string(length=8)
fname = '%s/%s/%s.%s.%s' % (
self.event.organizer.slug, self.event.slug, name.split('/')[-1], nonce, name.split('.')[-1]
)
# TODO: make sure pub is always correct
return 'pub/' + fname
class DeviceEventSettingsSerializer(EventSettingsSerializer):
default_fields = [

View File

@@ -1,5 +1,6 @@
from collections import OrderedDict
from django.core.exceptions import ValidationError
from rest_framework import serializers
@@ -27,3 +28,50 @@ class ListMultipleChoiceField(serializers.MultipleChoiceField):
]
return remove_duplicates_from_list(representation_data)
class UploadedFileField(serializers.Field):
default_error_messages = {
'required': 'No file was submitted.',
'not_found': 'The submitted file ID was not found.',
'invalid_type': 'The submitted file has a file type that is not allowed in this field.',
'size': 'The submitted file is too large to be used in this field.',
}
def __init__(self, *args, **kwargs):
self.allowed_types = kwargs.pop('allowed_types', None)
self.max_size = kwargs.pop('max_size', None)
super().__init__(*args, **kwargs)
def to_internal_value(self, data):
from pretix.base.models import CachedFile
request = self.context.get('request', None)
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
except (ValidationError, IndexError): # invalid uuid
self.fail('not_found')
except CachedFile.DoesNotExist:
self.fail('not_found')
if self.allowed_types and cf.type not in self.allowed_types:
self.fail('invalid_type')
if self.max_size and cf.file.size > self.max_size:
self.fail('size')
return cf.file
def to_representation(self, value):
if not value:
return None
try:
url = value.url
except AttributeError:
return None
request = self.context['request']
return request.build_absolute_uri(url)

View File

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from pretix.api.serializers.event import MetaDataField
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,
@@ -113,6 +114,9 @@ class ItemSerializer(I18nAwareModelSerializer):
variations = InlineItemVariationSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif'
), max_size=10 * 1024 * 1024)
class Meta:
model = Item
@@ -123,7 +127,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data')
read_only_fields = ('has_variations', 'picture')
read_only_fields = ('has_variations',)
def validate(self, data):
data = super().validate(data)

View File

@@ -3,6 +3,7 @@ from collections import Counter, defaultdict
from decimal import Decimal
import pycountry
from django.core.files import File
from django.db.models import F, Q
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
@@ -17,13 +18,14 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher,
CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
SubEvent, TaxRule, Voucher,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
)
from pretix.base.pdf import get_variables
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.locking import NoLockManager
from pretix.base.services.pricing import get_price
@@ -94,12 +96,9 @@ class AnswerQuestionIdentifierField(serializers.Field):
class AnswerQuestionOptionsIdentifierField(serializers.Field):
def to_representation(self, instance: QuestionAnswer):
return [o.identifier for o in instance.options.all()]
class AnswerQuestionOptionsField(serializers.Field):
def to_representation(self, instance: QuestionAnswer):
return [o.pk for o in instance.options.all()]
if isinstance(instance, WrappedModel) or instance.pk:
return [o.identifier for o in instance.options.all()]
return []
class InlineSeatSerializer(I18nAwareModelSerializer):
@@ -112,12 +111,102 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
options = AnswerQuestionOptionsField(source='*', read_only=True)
def to_representation(self, instance):
r = super().to_representation(instance)
if r['answer'].startswith('file://') and instance.orderposition:
r['answer'] = reverse('api-v1:orderposition-answer', kwargs={
'organizer': instance.orderposition.order.event.organizer.slug,
'event': instance.orderposition.order.event.slug,
'pk': instance.orderposition.pk,
'question': instance.question_id,
}, request=self.context['request'])
return r
class Meta:
model = QuestionAnswer
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers')
def validate_question(self, q):
if q.event != self.context['event']:
raise ValidationError(
'The specified question does not belong to this event.'
)
return q
def _handle_file_upload(self, data):
try:
ao = self.context["request"].user or self.context["request"].auth
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(ao))}-{ao.pk}',
file__isnull=False,
pk=data['answer'][len("file:"):],
)
except (ValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
allowed_types = (
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
)
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > 10 * 1024 * 1024:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
data['options'] = []
data['answer'] = cf.file
return data
def validate(self, data):
if data.get('question').type == Question.TYPE_FILE:
return self._handle_file_upload(data)
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
if not data.get('options'):
raise ValidationError(
'You need to specify options if the question is of a choice type.'
)
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
raise ValidationError(
'You can specify at most one option for this question.'
)
for o in data.get('options'):
if o.question_id != data.get('question').pk:
raise ValidationError(
'The specified option does not belong to this question.'
)
data['answer'] = ", ".join([str(o) for o in data.get('options')])
else:
if data.get('options'):
raise ValidationError(
'You should not specify options if the question is not of a choice type.'
)
if data.get('question').type == Question.TYPE_BOOLEAN:
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
data['answer'] = 'True'
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
data['answer'] = 'False'
else:
raise ValidationError(
'Please specify "true" or "false" for boolean questions.'
)
elif data.get('question').type == Question.TYPE_NUMBER:
serializers.DecimalField(
max_digits=50,
decimal_places=25
).to_internal_value(data.get('answer'))
elif data.get('question').type == Question.TYPE_DATE:
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
elif data.get('question').type == Question.TYPE_TIME:
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
elif data.get('question').type == Question.TYPE_DATETIME:
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
return data
class CheckinSerializer(I18nAwareModelSerializer):
class Meta:
@@ -187,6 +276,9 @@ class PdfDataSerializer(serializers.Field):
if 'vars' not in self.context:
self.context['vars'] = get_variables(self.context['request'].event)
if 'vars_images' not in self.context:
self.context['vars_images'] = get_images(self.context['request'].event)
for k, f in self.context['vars'].items():
res[k] = f['evaluate'](instance, instance.order, ev)
@@ -201,17 +293,39 @@ class PdfDataSerializer(serializers.Field):
for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + k] = v
return res
res['images'] = {}
for k, f in self.context['vars_images'].items():
if 'etag' in f:
has_image = etag = f['etag'](instance, instance.order, ev)
else:
has_image = f['etag'](instance, instance.order, ev)
etag = None
if has_image:
url = reverse('api-v1:orderposition-pdf_image', kwargs={
'organizer': instance.order.event.organizer.slug,
'event': instance.order.event.slug,
'pk': instance.pk,
'key': k,
}, request=self.context['request'])
if etag:
url += f'#etag={etag}'
res['images'][k] = url
else:
res['images'][k] = None
return res
class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True)
checkins = CheckinSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True)
downloads = PositionDownloadsField(source='*')
downloads = PositionDownloadsField(source='*', read_only=True)
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*')
pdf_data = PdfDataSerializer(source='*', read_only=True)
seat = InlineSeatSerializer(read_only=True)
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
class Meta:
model = OrderPosition
@@ -219,12 +333,99 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
'company', 'street', 'zipcode', 'city', 'country', 'state',
'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'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields.pop('pdf_data')
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = [
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email',
]
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in update_fields:
setattr(instance, attr, value)
instance.save(update_fields=update_fields)
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(an.name, an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition):
@@ -336,7 +537,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderRefund
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider')
class OrderURLField(serializers.URLField):
@@ -425,7 +626,17 @@ class OrderSerializer(I18nAwareModelSerializer):
return instance
class AnswerQuestionOptionsField(serializers.Field):
def to_representation(self, instance: QuestionAnswer):
return [o.pk for o in instance.options.all()]
class SimulatedAnswerSerializer(AnswerSerializer):
options = AnswerQuestionOptionsField(read_only=True, source='*')
class SimulatedOrderPositionSerializer(OrderPositionSerializer):
answers = SimulatedAnswerSerializer(many=True)
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
@@ -452,62 +663,8 @@ class PriceCalcSerializer(serializers.Serializer):
del self.fields['subevent']
class AnswerCreateSerializer(I18nAwareModelSerializer):
class Meta:
model = QuestionAnswer
fields = ('question', 'answer', 'options')
def validate_question(self, q):
if q.event != self.context['event']:
raise ValidationError(
'The specified question does not belong to this event.'
)
return q
def validate(self, data):
if data.get('question').type == Question.TYPE_FILE:
raise ValidationError(
'File uploads are currently not supported via the API.'
)
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
if not data.get('options'):
raise ValidationError(
'You need to specify options if the question is of a choice type.'
)
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
raise ValidationError(
'You can specify at most one option for this question.'
)
data['answer'] = ", ".join([str(o) for o in data.get('options')])
else:
if data.get('options'):
raise ValidationError(
'You should not specify options if the question is not of a choice type.'
)
if data.get('question').type == Question.TYPE_BOOLEAN:
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
data['answer'] = 'True'
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
data['answer'] = 'False'
else:
raise ValidationError(
'Please specify "true" or "false" for boolean questions.'
)
elif data.get('question').type == Question.TYPE_NUMBER:
serializers.DecimalField(
max_digits=50,
decimal_places=25
).to_internal_value(data.get('answer'))
elif data.get('question').type == Question.TYPE_DATE:
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
elif data.get('question').type == Question.TYPE_TIME:
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
elif data.get('question').type == Question.TYPE_DATETIME:
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
return data
class AnswerCreateSerializer(AnswerSerializer):
pass
class OrderFeeCreateSerializer(I18nAwareModelSerializer):
@@ -1044,8 +1201,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos.save()
for answ_data in answers_data:
options = answ_data.pop('options', [])
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = pos.answers.create(**answ_data, answer='')
answ.file.save(an.name, an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
else:
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
pos_map[pos.positionid] = pos
if not simulate:
@@ -1194,7 +1359,7 @@ class OrderRefundCreateSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderRefund
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info')
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info', 'comment')
def create(self, validated_data):
pid = validated_data.pop('payment', None)

View File

@@ -1,13 +1,15 @@
import logging
from decimal import Decimal
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from hierarkey.proxy import HierarkeyProxy
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.auth import get_auth_backends
from pretix.base.i18n import get_language_without_region
from pretix.base.models import (
@@ -16,9 +18,11 @@ from pretix.base.models import (
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail
from pretix.base.settings import DEFAULTS, validate_organizer_settings
from pretix.base.settings import validate_organizer_settings
from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__)
class OrganizerSerializer(I18nAwareModelSerializer):
class Meta:
@@ -207,8 +211,10 @@ class TeamMemberSerializer(serializers.ModelSerializer):
)
class OrganizerSettingsSerializer(serializers.Serializer):
class OrganizerSettingsSerializer(SettingsSerializer):
default_fields = [
'contact_mail',
'imprint_url',
'organizer_info_text',
'event_list_type',
'event_list_availability',
@@ -225,40 +231,13 @@ class OrganizerSettingsSerializer(serializers.Serializer):
'theme_color_danger',
'theme_color_background',
'theme_round_borders',
'primary_font'
'primary_font',
'organizer_logo_image'
]
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
self.changed_data = []
super().__init__(*args, **kwargs)
for fname in self.default_fields:
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
if callable(kwargs):
kwargs = kwargs()
kwargs.setdefault('required', False)
kwargs.setdefault('allow_null', True)
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
if callable(form_kwargs):
form_kwargs = form_kwargs()
if 'serializer_class' not in DEFAULTS[fname]:
raise ValidationError('{} has no serializer class'.format(fname))
f = DEFAULTS[fname]['serializer_class'](
**kwargs
)
f._label = form_kwargs.get('label', fname)
f._help_text = form_kwargs.get('help_text')
self.fields[fname] = f
def update(self, instance: HierarkeyProxy, validated_data):
for attr, value in validated_data.items():
if value is None:
instance.delete(attr)
self.changed_data.append(attr)
elif instance.get(attr, as_type=type(value)) != value:
instance.set(attr, value)
self.changed_data.append(attr)
return instance
def validate(self, data):
data = super().validate(data)
@@ -266,3 +245,11 @@ class OrganizerSettingsSerializer(serializers.Serializer):
settings_dict.update(data)
validate_organizer_settings(self.organizer, settings_dict)
return data
def get_new_filename(self, name: str) -> str:
nonce = get_random_string(length=8)
fname = '%s/%s.%s.%s' % (
self.organizer.slug, name.split('/')[-1], nonce, name.split('.')[-1]
)
# TODO: make sure pub is always correct
return 'pub/' + fname

View File

@@ -0,0 +1,77 @@
import logging
from django.core.files import File
from django.core.files.storage import default_storage
from django.db.models.fields.files import FieldFile
from hierarkey.proxy import HierarkeyProxy
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.fields import UploadedFileField
from pretix.base.settings import DEFAULTS
logger = logging.getLogger(__name__)
class SettingsSerializer(serializers.Serializer):
default_fields = []
def __init__(self, *args, **kwargs):
self.changed_data = []
super().__init__(*args, **kwargs)
for fname in self.default_fields:
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
if callable(kwargs):
kwargs = kwargs()
kwargs.setdefault('required', False)
kwargs.setdefault('allow_null', True)
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
if callable(form_kwargs):
form_kwargs = form_kwargs()
if 'serializer_class' not in DEFAULTS[fname]:
raise ValidationError('{} has no serializer class'.format(fname))
f = DEFAULTS[fname]['serializer_class'](
**kwargs
)
f._label = form_kwargs.get('label', fname)
f._help_text = form_kwargs.get('help_text')
f.parent = self
self.fields[fname] = f
def update(self, instance: HierarkeyProxy, validated_data):
for attr, value in validated_data.items():
if isinstance(value, FieldFile):
# Delete old file
fname = instance.get(attr, as_type=File)
if fname:
try:
default_storage.delete(fname.name)
except OSError: # pragma: no cover
logger.error('Deleting file %s failed.' % fname.name)
# Create new file
newname = default_storage.save(self.get_new_filename(value.name), value)
instance.set(attr, File(file=value, name=newname))
self.changed_data.append(attr)
elif isinstance(self.fields[attr], UploadedFileField):
if value is None:
fname = instance.get(attr, as_type=File)
if fname:
try:
default_storage.delete(fname.name)
except OSError: # pragma: no cover
logger.error('Deleting file %s failed.' % fname.name)
instance.delete(attr)
else:
# file is unchanged
continue
elif value is None:
instance.delete(attr)
self.changed_data.append(attr)
elif instance.get(attr, as_type=type(value)) != value:
instance.set(attr, value)
self.changed_data.append(attr)
return instance
def get_new_filename(self, name: str) -> str:
raise NotImplementedError()

View File

@@ -7,8 +7,8 @@ from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, device, event, exporters, item, oauth, order, organizer, user,
version, voucher, waitinglist, webhooks,
checkin, device, event, exporters, item, oauth, order, organizer, upload,
user, version, voucher, waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -95,6 +95,7 @@ urlpatterns = [
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
url(r"^upload$", upload.UploadView.as_view(), name="upload"),
url(r"^me$", user.MeView.as_view(), name="user.me"),
url(r"^version$", version.VersionView.as_view(), name="version"),
]

View File

@@ -22,7 +22,7 @@ from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, CheckinList, Event, Order, OrderPosition,
CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question,
)
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, perform_checkin,
@@ -302,7 +302,10 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
for q in op.item.questions.filter(ask_during_checkin=True):
if str(q.pk) in aws:
try:
given_answers[q] = q.clean_answer(aws[str(q.pk)])
if q.type == Question.TYPE_FILE:
given_answers[q] = self._handle_file_upload(aws[str(q.pk)])
else:
given_answers[q] = q.clean_answer(aws[str(q.pk)])
except ValidationError:
pass
@@ -352,3 +355,25 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
}, status=201)
def _handle_file_upload(self, data):
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
except (ValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
allowed_types = (
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
)
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > 10 * 1024 * 1024:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
return cf.file

View File

@@ -365,9 +365,13 @@ class EventSettingsView(views.APIView):
def get(self, request, *args, **kwargs):
if isinstance(request.auth, Device):
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event)
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={
'request': request
})
elif 'can_change_event_settings' in request.eventpermset:
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
'request': request
})
else:
raise PermissionDenied()
if 'explain' in request.GET:
@@ -382,7 +386,7 @@ class EventSettingsView(views.APIView):
def patch(self, request, *wargs, **kwargs):
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
event=request.event)
event=request.event, context={'request': request})
s.is_valid(raise_exception=True)
with transaction.atomic():
s.save()
@@ -392,6 +396,9 @@ class EventSettingsView(views.APIView):
}
)
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
regenerate_css.apply_async(args=(request.organizer.pk,))
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
regenerate_css.apply_async(args=(request.event.pk,))
s = EventSettingsSerializer(
instance=request.event.settings, event=request.event, context={
'request': request
})
return Response(s.data)

View File

@@ -1,4 +1,6 @@
import datetime
import mimetypes
import os
from decimal import Decimal
import django_filters
@@ -12,6 +14,7 @@ from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from PIL import Image
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
@@ -35,8 +38,9 @@ from pretix.base.models import (
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
TaxRule, TeamAPIToken, generate_secret,
)
from pretix.base.models.orders import RevokedTicketSecret
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
from pretix.base.payment import PaymentException
from pretix.base.pdf import get_images
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.invoices import (
@@ -763,7 +767,7 @@ with scopes_disabled():
}
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -783,6 +787,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
},
}
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def get_queryset(self):
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
qs = OrderPosition.all
@@ -908,6 +917,62 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
'tax_rule': tr.pk if tr else None,
})
@action(detail=True, url_name='answer', url_path=r'answer/(?P<question>\d+)')
def answer(self, request, **kwargs):
pos = self.get_object()
answer = get_object_or_404(
QuestionAnswer,
orderposition=self.get_object(),
question_id=kwargs.get('question')
)
if not answer.file:
raise NotFound()
ftype, ignored = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
)
return resp
@action(detail=True, url_name='pdf_image', url_path=r'pdf_image/(?P<key>[^/]+)')
def pdf_image(self, request, key, **kwargs):
pos = self.get_object()
image_vars = get_images(request.event)
if key not in image_vars:
raise NotFound('Unknown key')
image_file = image_vars[key]['evaluate'](pos, pos.order, pos.subevent or self.request.event)
if image_file is None:
raise NotFound('No image available')
if getattr(image_file, 'name', ''):
ftype, ignored = mimetypes.guess_type(image_file.name)
extension = os.path.basename(image_file.name).split('.')[-1]
else:
img = Image.open(image_file)
ftype = Image.MIME[img.format]
extensions = {
'GIF': 'gif', 'TIFF': 'tif', 'BMP': 'bmp', 'JPEG': 'jpg', 'PNG': 'png'
}
extension = extensions.get(img.format, 'bin')
if hasattr(image_file, 'seek'):
image_file.seek(0)
resp = FileResponse(image_file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
)
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
@@ -951,6 +1016,44 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
def update(self, request, *args, **kwargs):
partial = kwargs.get('partial', False)
if not partial:
return Response(
{"detail": "Method \"PUT\" not allowed."},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
with transaction.atomic():
old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
serializer.save()
new_data = serializer.data
if old_data != new_data:
log_data = self.request.data
if 'answers' in log_data:
for a in new_data['answers']:
log_data[f'question_{a["question"]}'] = a["answer"]
log_data.pop('answers', None)
serializer.instance.order.log_action(
'pretix.event.order.modified',
user=self.request.user,
auth=self.request.auth,
data={
'data': [
dict(
position=serializer.instance.pk,
**log_data
)
]
}
)
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPaymentSerializer

View File

@@ -425,7 +425,9 @@ class OrganizerSettingsView(views.APIView):
permission = 'can_change_organizer_settings'
def get(self, request, *args, **kwargs):
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer)
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
'request': request
})
if 'explain' in request.GET:
return Response({
fname: {
@@ -439,7 +441,9 @@ class OrganizerSettingsView(views.APIView):
def patch(self, request, *wargs, **kwargs):
s = OrganizerSettingsSerializer(
instance=request.organizer.settings, data=request.data, partial=True,
organizer=request.organizer
organizer=request.organizer, context={
'request': request
}
)
s.is_valid(raise_exception=True)
with transaction.atomic():
@@ -451,5 +455,7 @@ class OrganizerSettingsView(views.APIView):
)
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
regenerate_organizer_css.apply_async(args=(request.organizer.pk,))
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer)
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
'request': request
})
return Response(s.data)

View File

@@ -0,0 +1,55 @@
import datetime
from django.utils.timezone import now
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import FileUploadParser
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
from pretix.api.auth.token import TeamTokenAuthentication
from pretix.base.models import CachedFile
ALLOWED_TYPES = {
'image/gif': {'.gif'},
'image/jpeg': {'.jpg', '.jpeg'},
'image/png': {'.png'},
'application/pdf': {'.pdf'},
}
class UploadView(APIView):
authentication_classes = (
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
)
parser_classes = [FileUploadParser]
permission_classes = [AnyAuthenticatedClientPermission]
def post(self, request):
if 'file' not in request.data:
raise ValidationError('No file has been submitted.')
file_obj = request.data['file']
content_type = file_obj.content_type.split(";")[0] # ignore e.g. "; charset=…"
if content_type not in ALLOWED_TYPES:
raise ValidationError('Content type "{type}" is not allowed'.format(type=content_type))
if not any(file_obj.name.endswith(ext) for ext in ALLOWED_TYPES[content_type]):
raise ValidationError('File name "{name}" has an invalid extension for type "{type}"'.format(
name=file_obj.name,
type=content_type
))
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
date=now(),
web_download=False,
filename=file_obj.name,
type=content_type,
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}'
)
cf.file.save(file_obj.name, file_obj)
cf.save()
return Response({
'id': f'file:{cf.pk}'
}, status=201)

View File

@@ -6,6 +6,7 @@ from rest_framework.views import APIView
from pretix import __version__
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
from pretix.api.auth.token import TeamTokenAuthentication
@@ -48,6 +49,7 @@ class VersionView(APIView):
authentication_classes = (
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
)
permission_classes = [AnyAuthenticatedClientPermission]
def get(self, request, format=None):
return Response({

View File

@@ -427,28 +427,30 @@ def base_placeholders(sender, **kwargs):
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format(
order.full_code,
build_absolute_uri(event, 'presale:event.order', kwargs={
build_absolute_uri(event, 'presale:event.order.open', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order.code,
'secret': order.secret
'secret': order.secret,
'hash': order.email_confirm_hash(),
}),
)
for order in orders
), lambda event: '\n' + '\n\n'.join(
'* {} - {}'.format(
'{}-{}'.format(event.slug.upper(), order['code']),
build_absolute_uri(event, 'presale:event.order', kwargs={
build_absolute_uri(event, 'presale:event.order.open', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order['code'],
'secret': order['secret']
'secret': order['secret'],
'hash': order['hash'],
}),
)
for order in [
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'},
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'},
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd'}
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
]
),
),
@@ -466,7 +468,8 @@ def base_placeholders(sender, **kwargs):
'68CYU2H6ZTP3WLK5'
),
SimpleFunctionalMailTextPlaceholder(
'voucher_list', ['voucher_list'], lambda voucher_list: '\n'.join(voucher_list),
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
),
SimpleFunctionalMailTextPlaceholder(

View File

@@ -1,4 +1,5 @@
import io
import re
import tempfile
from collections import OrderedDict, namedtuple
from decimal import Decimal
@@ -10,11 +11,21 @@ from django.db.models import QuerySet
from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _
from openpyxl import Workbook
from openpyxl.cell.cell import KNOWN_TYPES
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES
from pretix.base.models import Event
def excel_safe(val):
if not isinstance(val, KNOWN_TYPES):
val = str(val)
if isinstance(val, str):
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
return val
class BaseExporter:
"""
This is the base class for all data exporters
@@ -181,7 +192,7 @@ class ListExporter(BaseExporter):
total = line.total
continue
ws.append([
str(val) if not isinstance(val, KNOWN_TYPES) else val
excel_safe(val) if not isinstance(val, KNOWN_TYPES) else val
for val in line
])
if total:
@@ -242,7 +253,10 @@ class MultiSheetListExporter(ListExporter):
pass
def iterate_sheet(self, form_data, sheet):
raise NotImplementedError() # noqa
if hasattr(self, 'iterate_' + sheet):
yield from getattr(self, 'iterate_' + sheet)(form_data)
else:
raise NotImplementedError() # noqa
def _render_sheet_csv(self, form_data, sheet, output_file=None, **kwargs):
total = 0
@@ -288,6 +302,9 @@ class MultiSheetListExporter(ListExporter):
n_sheets = len(self.sheets)
for i_sheet, (s, l) in enumerate(self.sheets):
ws = wb.create_sheet(str(l))
if hasattr(self, 'prepare_xlsx_sheet_' + s):
getattr(self, 'prepare_xlsx_sheet_' + s)(ws)
total = 0
counter = 0
for i, line in enumerate(self.iterate_sheet(form_data, sheet=s)):
@@ -295,7 +312,7 @@ class MultiSheetListExporter(ListExporter):
total = line.total
continue
ws.append([
str(val) if not isinstance(val, KNOWN_TYPES) else val
excel_safe(val)
for val in line
])
if total:

View File

@@ -13,6 +13,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext, gettext_lazy as _, pgettext
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
from ..services.export import ExportError
from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
@@ -66,7 +67,7 @@ class InvoiceExporterMixin:
)
def invoices_queryset(self, form_data: dict):
qs = Invoice.objects.filter(event__in=self.events)
qs = Invoice.objects.filter(event__in=self.events).select_related('order')
if form_data.get('payment_provider'):
qs = qs.annotate(
@@ -111,14 +112,16 @@ class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
if not i.file:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
if not i.file:
raise ExportError('Could not generate PDF for invoice {nr}'.format(nr=i.full_invoice_no))
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
zipf.writestr('{}-{}.pdf'.format(i.number, i.order.code), i.file.read())
i.file.close()
except FileNotFoundError:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
zipf.writestr('{}-{}.pdf'.format(i.number, i.order.code), i.file.read())
i.file.close()
counter += 1
if total and counter % max(10, total // 100) == 0:

View File

@@ -59,6 +59,12 @@ class OrderListExporter(MultiSheetListExporter):
initial=False,
required=False
)),
('group_multiple_choice',
forms.BooleanField(
label=_('Show multiple choice answers grouped in one column'),
initial=False,
required=False
)),
]
)
@@ -449,9 +455,14 @@ class OrderListExporter(MultiSheetListExporter):
for q in questions:
if q.type == Question.TYPE_CHOICE_MULTIPLE:
options[q.pk] = []
for o in q.options.all():
headers.append(str(q.question) + ' ' + str(o.answer))
options[q.pk].append(o)
if form_data['group_multiple_choice']:
for o in q.options.all():
options[q.pk].append(o)
headers.append(str(q.question))
else:
for o in q.options.all():
headers.append(str(q.question) + ' ' + str(o.answer))
options[q.pk].append(o)
else:
headers.append(str(q.question))
headers += [
@@ -551,8 +562,11 @@ class OrderListExporter(MultiSheetListExporter):
acache[a.question_id] = str(a)
for q in questions:
if q.type == Question.TYPE_CHOICE_MULTIPLE:
for o in options[q.pk]:
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
if form_data['group_multiple_choice']:
row.append(", ".join(str(o.answer) for o in options[q.pk] if o.pk in acache.get(q.pk, set())))
else:
for o in options[q.pk]:
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
else:
row.append(acache.get(q.pk, ''))
@@ -638,7 +652,7 @@ class PaymentListExporter(ListExporter):
headers = [
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Status code'), _('Amount'), _('Payment method')
_('Status code'), _('Amount'), _('Payment method'), _('Comment')
]
yield headers
@@ -660,7 +674,8 @@ class PaymentListExporter(ListExporter):
obj.get_state_display(),
obj.state,
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
provider_names.get(obj.provider, obj.provider)
provider_names.get(obj.provider, obj.provider),
obj.comment if isinstance(obj, OrderRefund) else "",
]
yield row

View File

@@ -9,20 +9,19 @@ import pycountry
import pytz
import vat_moss.errors
import vat_moss.id
from babel import localedata
from babel import Locale
from django import forms
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db.models import QuerySet
from django.forms import Select
from django.utils import translation
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone
from django.utils.translation import (
get_language, gettext_lazy as _, pgettext_lazy,
)
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries import countries
from django_countries.fields import Country, CountryField
from phonenumber_field.formfields import PhoneNumberField
@@ -35,7 +34,9 @@ from pretix.base.forms.widgets import (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
TimePickerWidget, UploadedFileWidget,
)
from pretix.base.i18n import get_language_without_region, language
from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language,
)
from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import (
EU_COUNTRIES, cc_to_vat_prefix, is_eu_country,
@@ -204,7 +205,47 @@ class NamePartsFormField(forms.MultiValueField):
return value
class WrappedPhonePrefixSelect(Select):
initial = None
def __init__(self, initial=None):
choices = [("", "---------")]
language = get_babel_locale() # changed from default implementation that used the django locale
locale = Locale(translation.to_locale(language))
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
prefix = "+%d" % prefix
if initial and initial in values:
self.initial = prefix
for country_code in values:
country_name = locale.territories.get(country_code)
if country_name:
choices.append((prefix, "{} {}".format(country_name, prefix)))
super().__init__(choices=sorted(choices, key=lambda item: item[1]))
def render(self, name, value, *args, **kwargs):
return super().render(name, value or self.initial, *args, **kwargs)
def get_context(self, name, value, attrs):
if value and self.choices[1][0] != value:
matching_choices = len([1 for p, c in self.choices if p == value])
if matching_choices > 1:
# Some countries share a phone prefix, for example +1 is used all over the Americas.
# This causes a UX problem: If the default value or the existing data is +12125552368,
# the widget will just show the first <option> entry with value="+1" as selected,
# which alphabetically is America Samoa, although most numbers statistically are from
# the US. As a workaround, we detect this case and add an aditional choice value with
# just <option value="+1">+1</option> without an explicit country.
self.choices.insert(1, (value, value))
context = super().get_context(name, value, attrs)
return context
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def __init__(self, attrs=None, initial=None):
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput())
super(PhoneNumberPrefixWidget, self).__init__(widgets, attrs)
def render(self, name, value, attrs=None, renderer=None):
output = super().render(name, value, attrs, renderer)
return mark_safe(self.format_output(output))
@@ -403,6 +444,7 @@ class BaseQuestionsForm(forms.Form):
c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
cc = None
state = None
if fprefix + 'country' in self.data:
cc = str(self.data[fprefix + 'country'])
elif country:
@@ -411,6 +453,7 @@ class BaseQuestionsForm(forms.Form):
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
state = (cartpos.state if cartpos else orderpos.state)
elif fprefix + 'state' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'state']
@@ -419,6 +462,7 @@ class BaseQuestionsForm(forms.Form):
label=pgettext_lazy('address', 'State'),
required=False,
choices=c,
initial=state,
widget=forms.Select(attrs={
'autocomplete': 'address-level1',
}),
@@ -564,13 +608,7 @@ class BaseQuestionsForm(forms.Form):
if q.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
elif q.type == Question.TYPE_PHONENUMBER:
babel_locale = 'en'
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
if localedata.exists(get_language()):
babel_locale = get_language()
elif localedata.exists(get_language()[:2]):
babel_locale = get_language()[:2]
with language(babel_locale):
with language(get_babel_locale()):
default_country = guess_country(event)
default_prefix = None
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
@@ -655,8 +693,9 @@ class BaseQuestionsForm(forms.Form):
if not self.all_optional:
for q in question_cache.values():
answer = d.get('question_%d' % q.pk)
if question_is_required(q) and not answer and answer != 0:
raise ValidationError({'question_%d' % q.pk: [_('This field is required')]})
field = self['question_%d' % q.pk]
if question_is_required(q) and not answer and answer != 0 and not field.errors:
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
return d
@@ -812,11 +851,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
) and len(data.get('name_parts', {})) == 1:
# Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = ''
if data.get('vat_id') and is_eu_country(data.get('country')) and data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
raise ValidationError(_('Your VAT ID does not match the selected country.'))
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
elif self.validate_vat_id and data.get('is_business') and is_eu_country(data.get('country')) and data.get('vat_id'):
if data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
raise ValidationError(_('Your VAT ID does not match the selected country.'))
try:
result = vat_moss.id.validate(data.get('vat_id'))
if result:

View File

@@ -16,12 +16,15 @@ class DatePickerWidget(forms.DateInput):
date_attrs = dict(attrs)
date_attrs.setdefault('class', 'form-control')
date_attrs['class'] += ' datepickerfield'
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
date_attrs['autocomplete'] = 'off'
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
date_attrs['placeholder'] = now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df)
def placeholder():
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df)
date_attrs['placeholder'] = lazy(placeholder, str)
forms.DateInput.__init__(self, date_attrs, date_format)
@@ -34,12 +37,15 @@ class TimePickerWidget(forms.TimeInput):
time_attrs = dict(attrs)
time_attrs.setdefault('class', 'form-control')
time_attrs['class'] += ' timepickerfield'
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
time_attrs['autocomplete'] = 'off'
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
time_attrs['placeholder'] = now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(tf)
def placeholder():
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf)
time_attrs['placeholder'] = lazy(placeholder, str)
forms.TimeInput.__init__(self, time_attrs, time_format)
@@ -105,8 +111,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
time_attrs.setdefault('autocomplete', 'off')
date_attrs['class'] += ' datepickerfield'
time_attrs['class'] += ' timepickerfield'
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
date_attrs['autocomplete'] = 'off'
time_attrs['autocomplete'] = 'off'
if min_date:
date_attrs['data-min'] = (
min_date if isinstance(min_date, date) else min_date.astimezone(get_current_timezone()).date()

View File

@@ -1,5 +1,6 @@
from contextlib import contextmanager
from babel import localedata
from django.conf import settings
from django.utils import translation
from django.utils.formats import date_format, number_format
@@ -69,6 +70,16 @@ class LazyNumber:
ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
def get_babel_locale():
babel_locale = 'en'
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
if localedata.exists(translation.get_language()):
babel_locale = translation.get_language()
elif localedata.exists(translation.get_language()[:2]):
babel_locale = translation.get_language()[:2]
return babel_locale
def get_language_without_region(lng=None):
"""
Returns the currently active language, but strips what pretix calls a ``region``. For example,

View File

@@ -12,12 +12,10 @@ from django.utils.formats import date_format, localize
from django.utils.translation import (
get_language, gettext, gettext_lazy, pgettext,
)
from PIL.Image import BICUBIC
from reportlab.lib import pagesizes
from reportlab.lib.enums import TA_LEFT, TA_RIGHT
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfbase.ttfonts import TTFont
@@ -31,6 +29,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Invoice, Order
from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import ThumbnailingImageReader
logger = logging.getLogger(__name__)
@@ -221,26 +220,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
return 'invoice.pdf', 'application/pdf', buffer.read()
class ThumbnailingImageReader(ImageReader):
def resize(self, width, height, dpi):
if width is None:
width = height * self._image.size[0] / self._image.size[1]
if height is None:
height = width * self._image.size[1] / self._image.size[0]
self._image.thumbnail(
size=(int(width * dpi / 72), int(height * dpi / 72)),
resample=BICUBIC
)
self._data = None
return width, height
def _jpeg_fh(self):
# Bypass a reportlab-internal optimization that falls back to the original
# file handle if the file is a JPEG, and therefore does not respect the
# (smaller) size of the modified image.
return None
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
identifier = 'classic'
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
@@ -276,8 +255,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas):
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet[
'InvoiceFrom'])
p = Paragraph(
bleach.clean(self.invoice.full_invoice_from, tags=[]).strip().replace('\n', '<br />\n'),
style=self.stylesheet['InvoiceFrom']
)
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
p_size = p.wrap(self.invoice_from_width, self.invoice_from_height)
p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top)
@@ -382,6 +363,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_event(self, canvas):
def shorten(txt):
txt = str(txt)
txt = bleach.clean(txt, tags=[]).strip()
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
@@ -462,13 +444,18 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story = []
if self.invoice.custom_field:
story.append(Paragraph(
'{}: {}'.format(self.invoice.event.settings.invoice_address_custom_field, self.invoice.custom_field),
'{}: {}'.format(
bleach.clean(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.stylesheet['Normal']
))
if self.invoice.internal_reference:
story.append(Paragraph(
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
pgettext('invoice', 'Customer reference: {reference}').format(
reference=bleach.clean(self.invoice.internal_reference, tags=[]).strip().replace('\n', '<br />\n'),
),
self.stylesheet['Normal']
))
@@ -487,7 +474,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
))
if self.invoice.introductory_text:
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
story.append(Paragraph(
bleach.clean(self.invoice.introductory_text, tags=[]).strip().replace('\n', '<br />\n'),
self.stylesheet['Normal']
))
story.append(Spacer(1, 10 * mm))
return story
@@ -587,10 +577,16 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 15 * mm))
if self.invoice.payment_provider_text:
story.append(Paragraph(self.invoice.payment_provider_text, self.stylesheet['Normal']))
story.append(Paragraph(
bleach.clean(self.invoice.payment_provider_text, tags=[]).strip().replace('\n', '<br />\n'),
self.stylesheet['Normal']
))
if self.invoice.additional_text:
story.append(Paragraph(self.invoice.additional_text, self.stylesheet['Normal']))
story.append(Paragraph(
bleach.clean(self.invoice.additional_text, tags=[]).strip().replace('\n', '<br />\n'),
self.stylesheet['Normal']
))
story.append(Spacer(1, 15 * mm))
tstyledata = [
@@ -722,7 +718,10 @@ class Modern1Renderer(ClassicInvoiceRenderer):
def _draw_invoice_from(self, canvas):
if not self.invoice.invoice_from:
return
c = self.invoice.address_invoice_from.strip().split('\n')
c = [
bleach.clean(l, tags=[]).strip().replace('\n', '<br />\n')
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = Paragraph(' · '.join(c), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)

View File

@@ -30,7 +30,7 @@ class Command(BaseCommand):
continue
if verbosity > 1:
self.stdout.write(f'Running {name}')
self.stdout.write(f'INFO Running {name}')
t0 = time.time()
try:
r = receiver(signal=periodic_task, sender=self)
@@ -40,13 +40,13 @@ class Command(BaseCommand):
if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception
capture_exception(err)
self.stdout.write(self.style.ERROR(f'FAIL: {str(err)}\n'))
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
else:
self.stdout.write(self.style.ERROR(f'FAIL: {str(err)}\n'))
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
traceback.print_exc()
else:
if options.get('verbosity') > 1:
if r is SKIPPED:
self.stdout.write(self.style.SUCCESS(f'Skipped {name}'))
self.stdout.write(self.style.SUCCESS(f'INFO Skipped {name}'))
else:
self.stdout.write(self.style.SUCCESS(f'Completed {name} in {round(time.time() - t0, 3)}s'))
self.stdout.write(self.style.SUCCESS(f'INFO Completed {name} in {round(time.time() - t0, 3)}s'))

View File

@@ -1,4 +1,6 @@
import json
import math
import time
from collections import defaultdict
from django.apps import apps
@@ -6,6 +8,7 @@ from django.conf import settings
from django.db import connection
from pretix.base.models import Event, Invoice, Order, OrderPosition, Organizer
from pretix.celery_app import app
if settings.HAS_REDIS:
import django_redis
@@ -248,6 +251,19 @@ def metric_values():
else:
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
if settings.HAS_CELERY:
client = app.broker_connection().channel().client
for q in settings.CELERY_TASK_QUEUES:
llen = client.llen(q.name)
lfirst = client.lindex(q.name, -1)
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
if lfirst:
ldata = json.loads(lfirst)
dt = time.time() - ldata.get('created', 0)
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
else:
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
return metrics

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.11 on 2021-01-15 09:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0174_merge_20201222_1031'),
]
operations = [
migrations.AddField(
model_name='orderrefund',
name='comment',
field=models.TextField(null=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.0.11 on 2021-02-05 15:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0175_orderrefund_comment'),
]
operations = [
migrations.AddField(
model_name='eventmetaproperty',
name='allowed_values',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='eventmetaproperty',
name='protected',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='eventmetaproperty',
name='required',
field=models.BooleanField(default=False),
),
]

View File

@@ -10,15 +10,18 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.mail import get_connection
from django.core.validators import RegexValidator
from django.core.validators import (
MaxValueValidator, MinLengthValidator, MinValueValidator, RegexValidator,
)
from django.db import models
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery, Value
from django.template.defaultfilters import date as _date
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField
@@ -353,8 +356,11 @@ class Event(EventMixin, LoggedModel):
"remembered, but you can also choose to use a random value. "
"This will be used in URLs, order codes, invoice numbers, and bank transfer references."),
validators=[
MinLengthValidator(
limit_value=2,
),
RegexValidator(
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*$",
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$",
message=_("The slug may only contain letters, numbers, dots and dashes."),
),
EventSlugBanlistValidator()
@@ -393,10 +399,18 @@ class Event(EventMixin, LoggedModel):
geo_lat = models.FloatField(
verbose_name=_("Latitude"),
null=True, blank=True,
validators=[
MinValueValidator(-90),
MaxValueValidator(90),
]
)
geo_lon = models.FloatField(
verbose_name=_("Longitude"),
null=True, blank=True,
validators=[
MinValueValidator(-180),
MaxValueValidator(180),
]
)
plugins = models.TextField(
null=False, blank=True,
@@ -848,7 +862,7 @@ class Event(EventMixin, LoggedModel):
Returns the currently configured ticket secret generator.
"""
tsgs = self.ticket_secret_generators
return tsgs[self.settings.ticket_secret_generator]
return tsgs.get(self.settings.ticket_secret_generator, tsgs.get('random'))
def get_data_shredders(self) -> dict:
"""
@@ -940,6 +954,18 @@ class Event(EventMixin, LoggedModel):
if not self.quotas.exists():
issues.append(_('You need to configure at least one quota to sell anything.'))
for mp in self.organizer.meta_properties.all():
if mp.required and not self.meta_data.get(mp.name):
issues.append(
('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
property=mp.name,
a_attr='href="%s#id_prop-%d-value"' % (
reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
mp.pk
)
)
)
responses = event_live_issues.send(self)
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
if response:
@@ -1121,10 +1147,18 @@ class SubEvent(EventMixin, LoggedModel):
geo_lat = models.FloatField(
verbose_name=_("Latitude"),
null=True, blank=True,
validators=[
MinValueValidator(-90),
MaxValueValidator(90),
]
)
geo_lon = models.FloatField(
verbose_name=_("Longitude"),
null=True, blank=True
null=True, blank=True,
validators=[
MinValueValidator(-180),
MaxValueValidator(180),
]
)
frontpage_text = I18nTextField(
null=True, blank=True,
@@ -1342,7 +1376,26 @@ class EventMetaProperty(LoggedModel):
],
verbose_name=_("Name"),
)
default = models.TextField(blank=True)
default = models.TextField(blank=True, verbose_name=_("Default value"))
protected = models.BooleanField(default=False,
verbose_name=_("Can only be changed by organizer-level administrators"))
required = models.BooleanField(
default=False, verbose_name=_("Required for events"),
help_text=_("If checked, an event can only be taken live if the property is set. In event series, its always "
"optional to set a value for individual dates")
)
allowed_values = models.TextField(
null=True, blank=True,
verbose_name=_("Valid values"),
help_text=_("If you keep this empty, any value is allowed. Otherwise, enter one possible value per line.")
)
def full_clean(self, exclude=None, validate_unique=True):
super().full_clean(exclude, validate_unique)
if self.default and self.required:
raise ValidationError(_("A property can either be required or have a default value, not both."))
if self.default and self.allowed_values and self.default not in self.allowed_values.splitlines():
raise ValidationError(_("You cannot set a default value that is not a valid value."))
class EventMetaValue(LoggedModel):

View File

@@ -1026,7 +1026,7 @@ class Question(LoggedModel):
(TYPE_PHONENUMBER, _("Phone number")),
)
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER]
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_PHONENUMBER]
event = models.ForeignKey(
Event,
@@ -1069,6 +1069,7 @@ class Question(LoggedModel):
)
ask_during_checkin = models.BooleanField(
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
)
hidden = models.BooleanField(

View File

@@ -2,7 +2,6 @@ import copy
import hashlib
import json
import logging
import os
import string
from collections import Counter
from datetime import datetime, time, timedelta
@@ -615,21 +614,26 @@ class Order(LockModel, LoggedModel):
return proposals
@staticmethod
def normalize_code(code):
tr = str.maketrans({
def normalize_code(code, is_fallback=False):
d = {
'2': 'Z',
'4': 'A',
'5': 'S',
'6': 'G',
})
}
if is_fallback:
d['8'] = 'B'
# 8 has been removed from the character set only in 2021, which means there are a lot of order codes
# with an 8 in it around. We only want to replace this when this is used in a fallback.
tr = str.maketrans(d)
return code.upper().translate(tr)
def assign_code(self):
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
# handwriting (2/Z, 4/A, 5/S, 6/G). This allows for better detection e.g. in incoming wire transfers that
# handwriting (2/Z, 4/A, 5/S, 6/G, 8/B). This allows for better detection e.g. in incoming wire transfers that
# might include OCR'd handwritten text
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ379')
iteration = 0
length = settings.ENTROPY['order_code']
while True:
@@ -661,6 +665,8 @@ class Order(LockModel, LoggedModel):
related to the order. This checks order status and modification deadlines. It also
returns ``False`` if there are no questions that can be answered.
"""
from .checkin import Checkin
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
return False
@@ -676,10 +682,21 @@ class Order(LockModel, LoggedModel):
if modify_deadline is not None and now() > modify_deadline:
return False
positions = list(
self.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
).select_related('item').prefetch_related('item__questions')
)
if not self.event.settings.allow_modifications_after_checkin:
for cp in positions:
if cp.has_checkin:
return False
if self.event.settings.get('invoice_address_asked', as_type=bool):
return True
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in self.positions.all().prefetch_related('item__questions'):
for cp in positions:
if (cp.item.admission and ask_names) or cp.item.questions.all():
return True
@@ -1219,6 +1236,9 @@ class AbstractPosition(models.Model):
else self.variation.quotas.filter(subevent=self.subevent))
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields', [])
if 'attendee_name_parts' in update_fields:
update_fields.append('attendee_name_cached')
self.attendee_name_cached = self.attendee_name
if self.attendee_name_parts is None:
self.attendee_name_parts = {}
@@ -1484,7 +1504,7 @@ class OrderPayment(models.Model):
OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if payment_sum - refund_sum < self.order.total:
logger.info('Confirmed payment {} but payment sum is {} and refund sum is.'.format(
logger.info('Confirmed payment {} but payment sum is {} and refund sum is {}.'.format(
self.full_id, payment_sum, refund_sum
))
return
@@ -1708,6 +1728,11 @@ class OrderRefund(models.Model):
max_length=255,
verbose_name=_("Payment provider")
)
comment = models.TextField(
verbose_name=_("Refund reason"),
help_text=_('May be shown to the end user or used e.g. as part of a payment reference.'),
null=True, blank=True
)
info = models.TextField(
verbose_name=_("Payment information"),
null=True, blank=True
@@ -1746,7 +1771,7 @@ class OrderRefund(models.Model):
Marks the refund as complete. This does not modify the state of the order.
:param user: The user who performed the change
:param user: The API auth token that performed the change
:param auth: The API auth token that performed the change
"""
self.state = self.REFUND_STATE_DONE
self.execution_date = self.execution_date or now()
@@ -2307,7 +2332,6 @@ def cachedticket_name(instance, filename: str) -> str:
no=instance.order_position.positionid,
code=instance.order_position.order.code,
secret=secret,
ext=os.path.splitext(filename)[1]
)

View File

@@ -1,9 +1,10 @@
import string
from datetime import date, datetime, time
from django.core.validators import RegexValidator
from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models
from django.db.models import Exists, OuterRef, Q
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now
@@ -38,8 +39,11 @@ class Organizer(LoggedModel):
"Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used "
"once. This is being used in URLs to refer to your organizer accounts and your events."),
validators=[
MinLengthValidator(
limit_value=2,
),
RegexValidator(
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]+$",
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$",
message=_("The slug may only contain letters, numbers, dots and dashes.")
),
OrganizerSlugBanlistValidator()
@@ -85,6 +89,15 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self)
@cached_property
def all_logentries_link(self):
return reverse(
'control:organizer.log',
kwargs={
'organizer': self.slug,
}
)
@property
def has_gift_cards(self):
return self.cache.get_or_set(

View File

@@ -9,7 +9,7 @@ import pytz
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import transaction
from django.dispatch import receiver
from django.forms import Form
@@ -706,12 +706,24 @@ class BasePaymentProvider:
It should return HTML code containing information regarding the current payment
status and, if applicable, next steps.
The default implementation returns the verbose name of the payment provider.
The default implementation returns an empty string.
:param order: The order object
"""
return ''
def payment_control_render_short(self, payment: OrderPayment) -> str:
"""
Will be called if the *event administrator* performs an action on the payment. Should
return a very short version of the payment method. Usually, this should return e.g.
a transaction ID or account identifier, but no information on status, dates, etc.
The default implementation falls back to ``payment_presale_render``.
:param order: The order object
"""
return self.payment_presale_render(payment)
def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str:
"""
Will be called if the *event administrator* views the details of a refund.
@@ -725,6 +737,19 @@ class BasePaymentProvider:
"""
return ''
def payment_presale_render(self, payment: OrderPayment) -> str:
"""
Will be called if the *ticket customer* views the details of a payment. This is
currently used e.g. when the customer requests a refund to show which payment
method is used for the refund. This should only include very basic information
about the payment, such as "VISA card ****9999", and never raw payment information.
The default implementation returns the public name of the payment provider.
:param order: The order object
"""
return self.public_name
def payment_refund_supported(self, payment: OrderPayment) -> bool:
"""
Will be called to check if the provider supports automatic refunding for this
@@ -760,6 +785,32 @@ class BasePaymentProvider:
"""
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
def new_refund_control_form_render(self, request: HttpRequest, order: Order) -> str:
"""
Render a form that will be shown to backend users when trying to create a new refund.
Usually, refunds are created from an existing payment object, e.g. if there is a credit card
payment and the credit card provider returns ``True`` from ``payment_refund_supported``, the system
will automatically create an ``OrderRefund`` and call ``execute_refund`` on that payment. This method
can and should not be used in that situation! Instead, by implementing this method you can add a refund
flow for this payment provider that starts without an existing payment. For example, even though an order
was paid by credit card, it could easily be refunded by SEPA bank transfer. In that case, the SEPA bank
transfer provider would implement this method and return a form that asks for the IBAN.
This method should return HTML or ``None``. All form fields should have a globally unique name.
"""
return
def new_refund_control_form_process(self, request: HttpRequest, amount: Decimal, order: Order) -> OrderRefund:
"""
Process a backend user's request to initiate a new refund with an amount of ``amount`` for ``order``.
This method should parse the input provided to the form created and either raise ``ValidationError``
or return an ``OrderRefund`` object in ``created`` state that has not yet been saved to the database.
The system will then call ``execute_refund`` on that object.
"""
raise ValidationError('Not implemented')
def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]):
"""
When personal data is removed from an event, this method is called to scrub payment-related data
@@ -899,7 +950,7 @@ class ManualPayment(BasePaymentProvider):
@property
def public_name(self):
return str(self.settings.get('public_name', as_type=LazyI18nString))
return str(self.settings.get('public_name', as_type=LazyI18nString) or _('Manual payment'))
@property
def settings_form_fields(self):

View File

@@ -1,4 +1,5 @@
import copy
import hashlib
import itertools
import logging
import os
@@ -35,9 +36,9 @@ from reportlab.platypus import Paragraph
from pretix.base.i18n import language
from pretix.base.invoice import ThumbnailingImageReader
from pretix.base.models import Order, OrderPosition
from pretix.base.models import Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_text_variables
from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format
from pretix.presale.style import get_fonts
@@ -154,6 +155,11 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Sample event name"),
"evaluate": lambda op, order, ev: str(ev.name)
}),
("event_series_name", {
"label": _("Event series"),
"editor_sample": _("Sample event name"),
"evaluate": lambda op, order, ev: str(order.event.name)
}),
("event_date", {
"label": _("Event date"),
"editor_sample": _("May 31st, 2017"),
@@ -339,6 +345,47 @@ DEFAULT_VARIABLES = OrderedDict((
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
}),
))
DEFAULT_IMAGES = OrderedDict([])
@receiver(layout_image_variables, dispatch_uid="pretix_base_layout_image_variables_questions")
def images_from_questions(sender, *args, **kwargs):
def get_answer(op, order, event, question_id, etag):
a = None
if op.addon_to:
if 'answers' in getattr(op.addon_to, '_prefetched_objects_cache', {}):
try:
a = [a for a in op.addon_to.answers.all() if a.question_id == question_id][0]
except IndexError:
pass
else:
a = op.addon_to.answers.filter(question_id=question_id).first()
if 'answers' in getattr(op, '_prefetched_objects_cache', {}):
try:
a = [a for a in op.answers.all() if a.question_id == question_id][0]
except IndexError:
pass
else:
a = op.answers.filter(question_id=question_id).first()
if not a or not a.file or not any(a.file.name.lower().endswith(e) for e in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff")):
return None
else:
if etag:
return hashlib.sha1(a.file.name.encode()).hexdigest()
return a.file
d = {}
for q in sender.questions.all():
if q.type != Question.TYPE_FILE:
continue
d['question_{}'.format(q.identifier)] = {
'label': _('Question: {question}').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk, etag=False),
'etag': partial(get_answer, question_id=q.pk, etag=True),
}
return d
@receiver(layout_text_variables, dispatch_uid="pretix_base_layout_text_variables_questions")
@@ -369,6 +416,8 @@ def variables_from_questions(sender, *args, **kwargs):
d = {}
for q in sender.questions.all():
if q.type == Question.TYPE_FILE:
continue
d['question_{}'.format(q.pk)] = {
'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question),
@@ -387,6 +436,15 @@ def _get_ia_name_part(key, op, order, ev):
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
def get_images(event):
v = copy.copy(DEFAULT_IMAGES)
for recv, res in layout_image_variables.send(sender=event):
v.update(res)
return v
def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES)
@@ -427,6 +485,7 @@ class Renderer:
self.layout = layout
self.background_file = background_file
self.variables = get_variables(event)
self.images = get_images(event)
self.event = event
if self.background_file:
self.bg_bytes = self.background_file.read()
@@ -514,6 +573,47 @@ class Renderer:
return '(error)'
return ''
def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
ev = self._get_ev(op, order)
if not o['content'] or o['content'] not in self.images:
image_file = None
else:
try:
image_file = self.images[o['content']]['evaluate'](op, order, ev)
except:
logger.exception('Failed to process variable.')
image_file = None
if image_file:
ir = ThumbnailingImageReader(image_file)
try:
ir.resize(float(o['width']) * mm, float(o['height']) * mm, 300)
except:
logger.exception("Can not resize image")
pass
canvas.drawImage(
image=ir,
x=float(o['left']) * mm,
y=float(o['bottom']) * mm,
width=float(o['width']) * mm,
height=float(o['height']) * mm,
preserveAspectRatio=True,
anchor='c', # centered in frame
mask='auto'
)
else:
canvas.saveState()
canvas.setFillColorRGB(.8, .8, .8, alpha=1)
canvas.rect(
x=float(o['left']) * mm,
y=float(o['bottom']) * mm,
width=float(o['width']) * mm,
height=float(o['height']) * mm,
stroke=0,
fill=1,
)
canvas.restoreState()
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
font = o['fontfamily']
if o['bold']:
@@ -572,6 +672,8 @@ class Renderer:
for o in self.layout:
if o['type'] == "barcodearea":
self._draw_barcodearea(canvas, op, o)
elif o['type'] == "imagearea":
self._draw_imagearea(canvas, op, order, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby":

View File

@@ -4,7 +4,7 @@ import struct
from cryptography.hazmat.backends.openssl.backend import Backend
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization.base import (
from cryptography.hazmat.primitives.serialization import (
Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key,
load_pem_public_key,
)

View File

@@ -3,6 +3,7 @@ from decimal import Decimal
from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
from django.utils.translation import gettext
from i18nfield.strings import LazyI18nString
from pretix.base.decimal import round_decimal
@@ -195,7 +196,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
if auto_refund:
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions)
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
comment=gettext('Event canceled'))
finally:
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
@@ -252,7 +254,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
if auto_refund:
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions)
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
comment=gettext('Event canceled'))
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)

View File

@@ -1,6 +1,7 @@
from datetime import timedelta
import dateutil
from django.core.files import File
from django.db import transaction
from django.db.models.functions import TruncDate
from django.dispatch import receiver
@@ -23,7 +24,7 @@ def get_logic_environment(ev):
elif t == 'date_from':
return ev.date_from
elif t == 'date_to':
return ev.date_to
return ev.date_to or ev.date_from
elif t == 'date_admission':
return ev.date_admission or ev.date_from
@@ -101,9 +102,11 @@ class RequiredQuestionsError(Exception):
def _save_answers(op, answers, given_answers):
written = False
for q, a in given_answers.items():
if not a:
if q in answers:
written = True
answers[q].delete()
else:
continue
@@ -112,6 +115,7 @@ def _save_answers(op, answers, given_answers):
qa = answers[q]
qa.answer = str(a.answer)
qa.save()
written = True
qa.options.clear()
else:
qa = op.answers.create(question=q, answer=str(a.answer))
@@ -121,10 +125,20 @@ def _save_answers(op, answers, given_answers):
qa = answers[q]
qa.answer = ", ".join([str(o) for o in a])
qa.save()
written = True
qa.options.clear()
else:
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
qa.options.add(*a)
elif isinstance(a, File):
if q in answers:
qa = answers[q]
else:
qa = op.answers.create(question=q, answer=str(a))
qa.file.save(a.name, a, save=False)
qa.answer = 'file://' + qa.file.name
qa.save()
written = True
else:
if q in answers:
qa = answers[q]
@@ -132,9 +146,14 @@ def _save_answers(op, answers, given_answers):
qa.save()
else:
op.answers.create(question=q, answer=str(a))
written = True
if written:
prefetched_objects_cache = getattr(op, '_prefetched_objects_cache', {})
if 'answers' in prefetched_objects_cache:
del prefetched_objects_cache['answers']
@transaction.atomic
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY):
@@ -154,18 +173,16 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
"""
dt = datetime or now()
# Lock order positions
op = OrderPosition.all.select_for_update().get(pk=op.pk)
checkin_questions = list(
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
)
if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING):
raise CheckInError(
_('This order position has been canceled.'),
'canceled' if canceled_supported else 'unpaid'
)
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
checkin_questions = list(
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
)
require_answers = []
if checkin_questions:
answers = {a.question: a for a in op.answers.all()}
@@ -175,81 +192,85 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
_save_answers(op, answers, given_answers)
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
raise CheckInError(
_('This order position has an invalid product for this check-in list.'),
'product'
)
elif clist.subevent_id and op.subevent_id != clist.subevent_id:
raise CheckInError(
_('This order position has an invalid date for this check-in list.'),
'product'
)
elif op.order.status != Order.STATUS_PAID and not force and not (
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
):
raise CheckInError(
_('This order is not marked as paid.'),
'unpaid'
)
elif require_answers and not force and questions_supported:
raise RequiredQuestionsError(
_('You need to answer questions to complete this check-in.'),
'incomplete',
require_answers
)
with transaction.atomic():
# Lock order positions
op = OrderPosition.all.select_for_update().get(pk=op.pk)
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
rule_data = LazyRuleVars(op, clist, dt)
logic = get_logic_environment(op.subevent or clist.event)
if not logic.apply(clist.rules, rule_data):
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
raise CheckInError(
_('This entry is not permitted due to custom rules.'),
'rules'
_('This order position has an invalid product for this check-in list.'),
'product'
)
elif clist.subevent_id and op.subevent_id != clist.subevent_id:
raise CheckInError(
_('This order position has an invalid date for this check-in list.'),
'product'
)
elif op.order.status != Order.STATUS_PAID and not force and not (
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
):
raise CheckInError(
_('This order is not marked as paid.'),
'unpaid'
)
elif require_answers and not force and questions_supported:
raise RequiredQuestionsError(
_('You need to answer questions to complete this check-in.'),
'incomplete',
require_answers
)
device = None
if isinstance(auth, Device):
device = auth
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
rule_data = LazyRuleVars(op, clist, dt)
logic = get_logic_environment(op.subevent or clist.event)
if not logic.apply(clist.rules, rule_data):
raise CheckInError(
_('This entry is not permitted due to custom rules.'),
'rules'
)
last_ci = op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce').first()
entry_allowed = (
type == Checkin.TYPE_EXIT or
clist.allow_multiple_entries or
last_ci is None or
(clist.allow_entry_after_exit and last_ci.type == Checkin.TYPE_EXIT)
)
device = None
if isinstance(auth, Device):
device = auth
if nonce and ((last_ci and last_ci.nonce == nonce) or op.checkins.filter(type=type, list=clist, device=device, nonce=nonce).exists()):
return
if entry_allowed or force:
ci = Checkin.objects.create(
position=op,
type=type,
list=clist,
datetime=dt,
device=device,
gate=device.gate if device else None,
nonce=nonce,
forced=force and not entry_allowed,
)
op.order.log_action('pretix.event.checkin', data={
'position': op.id,
'positionid': op.positionid,
'first': True,
'forced': force or op.order.status != Order.STATUS_PAID,
'datetime': dt,
'type': type,
'list': clist.pk
}, user=user, auth=auth)
checkin_created.send(op.order.event, checkin=ci)
else:
raise CheckInError(
_('This ticket has already been redeemed.'),
'already_redeemed',
last_ci = op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce').first()
entry_allowed = (
type == Checkin.TYPE_EXIT or
clist.allow_multiple_entries or
last_ci is None or
(clist.allow_entry_after_exit and last_ci.type == Checkin.TYPE_EXIT)
)
if nonce and ((last_ci and last_ci.nonce == nonce) or op.checkins.filter(type=type, list=clist, device=device, nonce=nonce).exists()):
return
if entry_allowed or force:
ci = Checkin.objects.create(
position=op,
type=type,
list=clist,
datetime=dt,
device=device,
gate=device.gate if device else None,
nonce=nonce,
forced=force and not entry_allowed,
)
op.order.log_action('pretix.event.checkin', data={
'position': op.id,
'positionid': op.positionid,
'first': True,
'forced': force or op.order.status != Order.STATUS_PAID,
'datetime': dt,
'type': type,
'list': clist.pk
}, user=user, auth=auth)
checkin_created.send(op.order.event, checkin=ci)
else:
raise CheckInError(
_('This ticket has already been redeemed.'),
'already_redeemed',
)
@receiver(order_placed, dispatch_uid="autocheckin_order_placed")
def order_placed(sender, **kwargs):

View File

@@ -291,6 +291,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
order = None
else:
with language(order.locale, event.settings.region):
if not event.settings.mail_attach_tickets:
attach_tickets = False
if position:
try:
position = order.positions.get(pk=position)

View File

@@ -2034,7 +2034,7 @@ _unset = object()
def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER,
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None):
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
notify_admin = False
error = False
if isinstance(order, int):
@@ -2059,6 +2059,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
order=order,
payment=None,
source=source,
comment=comment,
state=OrderRefund.REFUND_STATE_CREATED,
execution_date=now(),
amount=can_auto_refund_sum,
@@ -2096,6 +2097,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
source=source,
state=OrderRefund.REFUND_STATE_CREATED,
amount=value,
comment=comment,
provider=p.provider
)
order.log_action('pretix.event.order.refund.created', {
@@ -2125,6 +2127,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
with transaction.atomic():
r = order.refunds.create(
source=source,
comment=comment,
state=OrderRefund.REFUND_STATE_CREATED,
amount=refund_amount - can_auto_refund_sum,
provider='manual'
@@ -2149,13 +2152,14 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
@scopes_disabled()
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False):
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, comment=None):
try:
try:
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
cancellation_fee)
if try_auto_refund:
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard)
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
comment=comment)
return ret
except LockTimeoutException:
self.retry()

View File

@@ -36,7 +36,7 @@ def validate_plan_change(event, subevent, plan):
'already sold.'), leftovers[0])
def generate_seats(event, subevent, plan, mapping):
def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
current_seats = {}
for s in event.seats.select_related('product').annotate(
has_op=Count('orderposition'), has_v=Count('vouchers')
@@ -68,7 +68,10 @@ def generate_seats(event, subevent, plan, mapping):
update(seat, 'seat_label', ss.seat_label),
update(seat, 'x', ss.x),
update(seat, 'y', ss.y),
])
] + (
[update(seat, 'blocked', ss.guid in blocked_guids)]
if blocked_guids else []
))
if updated:
seat.save()
else:
@@ -84,6 +87,7 @@ def generate_seats(event, subevent, plan, mapping):
seat_label=ss.seat_label,
x=ss.x,
y=ss.y,
blocked=bool(blocked_guids and ss.guid in blocked_guids),
product=p,
))

View File

@@ -75,16 +75,19 @@ def shred(event: Event, fileid: str, confirm_code: str) -> None:
indexdata = json.loads(zipfile.read('index.json').decode())
if indexdata['organizer'] != event.organizer.slug or indexdata['event'] != event.slug:
raise ShredError(_("This file is from a different event."))
if indexdata['confirm_code'] != confirm_code:
raise ShredError(_("The confirm code you entered was incorrect."))
if event.logentry_set.filter(datetime__gte=parse(indexdata['time'])):
raise ShredError(_("Something happened in your event after the export, please try again."))
shredders = []
for s in indexdata['shredders']:
shredder = known_shredders.get(s)
if not shredder:
continue
shredders.append(shredder)
if any(shredder.require_download_confirmation for shredder in shredders):
if indexdata['confirm_code'] != confirm_code:
raise ShredError(_("The confirm code you entered was incorrect."))
if event.logentry_set.filter(datetime__gte=parse(indexdata['time'])):
raise ShredError(_("Something happened in your event after the export, please try again."))
for shredder in shredders:
shredder.shred_data()
cf.file.delete(save=False)

View File

@@ -45,7 +45,8 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
continue
if wle.subevent and not wle.subevent.presale_is_running:
continue
if not wle.item.active or (wle.variation and not wle.variation.active):
if not wle.item.is_available():
gone.add((wle.item, wle.variation, wle.subevent))
continue
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)

View File

@@ -13,6 +13,7 @@ from django.core.validators import (
MaxValueValidator, MinValueValidator, RegexValidator,
)
from django.db.models import Model
from django.utils.text import format_lazy
from django.utils.translation import (
gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy,
)
@@ -21,7 +22,9 @@ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from rest_framework import serializers
from pretix.api.serializers.fields import ListMultipleChoiceField
from pretix.api.serializers.fields import (
ListMultipleChoiceField, UploadedFileField,
)
from pretix.api.serializers.i18n import I18nField
from pretix.base.models.tax import TaxRule
from pretix.base.reldate import (
@@ -29,7 +32,7 @@ from pretix.base.reldate import (
SerializerRelativeDateField, SerializerRelativeDateTimeField,
)
from pretix.control.forms import (
FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
)
from pretix.helpers.countries import CachedCountries
@@ -1080,6 +1083,15 @@ DEFAULTS = {
help_text=_('If your event series has more than 50 dates in the future, only the month or week calendar can be used.')
),
},
'allow_modifications_after_checkin': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Allow customers to modify their information after they checked in."),
)
},
'last_order_modification_date': {
'default': None,
'type': RelativeDateWrapper,
@@ -1223,6 +1235,21 @@ DEFAULTS = {
"e.g. to explain choosing a lower refund will help your organization.")
)
},
'cancel_allow_user_paid_adjust_fees_step': {
'default': None,
'type': Decimal,
'form_class': forms.DecimalField,
'serializer_class': serializers.DecimalField,
'serializer_kwargs': dict(
max_digits=10, decimal_places=2
),
'form_kwargs': dict(
max_digits=10, decimal_places=2,
label=_("Step size for reduction amount"),
help_text=_('By default, customers can choose an arbitrary amount for you to keep. If you set this to e.g. '
'10, they will only be able to choose values in increments of 10.')
)
},
'cancel_allow_user_paid_require_approval': {
'default': 'False',
'type': bool,
@@ -1296,6 +1323,19 @@ DEFAULTS = {
'default': 'classic',
'type': str
},
'mail_attach_tickets': {
'default': 'True',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Attach ticket files"),
help_text=format_lazy(
_("Tickets will never be attached if they're larger than {size} to avoid email delivery problems."),
size='4 MB'
),
)
},
'mail_attach_ical': {
'default': 'False',
'type': bool,
@@ -1784,19 +1824,66 @@ Your {event} team"""))
},
'logo_image': {
'default': None,
'type': File
'type': File,
'form_class': ExtFileField,
'form_kwargs': dict(
label=_('Header image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
max_size=10 * 1024 * 1024,
help_text=_('If you provide a logo image, we will by default not show your event name and date '
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
'can increase the size with the setting below. We recommend not using small details on the picture '
'as it will be resized on smaller screens.')
),
'serializer_class': UploadedFileField,
'serializer_kwargs': dict(
allowed_types=[
'image/png', 'image/jpeg', 'image/gif'
],
max_size=10 * 1024 * 1024,
)
},
'logo_image_large': {
'default': 'False',
'type': bool
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_('Use header image in its full size'),
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
)
},
'logo_show_title': {
'default': 'True',
'type': bool
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_('Show event title even if a header image is present'),
help_text=_('The title will only be shown on the event front page.'),
)
},
'organizer_logo_image': {
'default': None,
'type': File
'type': File,
'form_class': ExtFileField,
'form_kwargs': dict(
label=_('Header image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
max_size=10 * 1024 * 1024,
help_text=_('If you provide a logo image, we will by default not show your organization name '
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
'can increase the size with the setting below. We recommend not using small details on the picture '
'as it will be resized on smaller screens.')
),
'serializer_class': UploadedFileField,
'serializer_kwargs': dict(
allowed_types=[
'image/png', 'image/jpeg', 'image/gif'
],
max_size=10 * 1024 * 1024,
)
},
'organizer_logo_image_large': {
'default': 'False',
@@ -1810,11 +1897,43 @@ Your {event} team"""))
},
'og_image': {
'default': None,
'type': File
'type': File,
'form_class': ExtFileField,
'form_kwargs': dict(
label=_('Social media image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
max_size=10 * 1024 * 1024,
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
'only the center square is shown. If you do not fill this, we will use the logo given above.')
),
'serializer_class': UploadedFileField,
'serializer_kwargs': dict(
allowed_types=[
'image/png', 'image/jpeg', 'image/gif'
],
max_size=10 * 1024 * 1024,
)
},
'invoice_logo_image': {
'default': None,
'type': File
'type': File,
'form_class': ExtFileField,
'form_kwargs': dict(
label=_('Logo image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
required=False,
max_size=10 * 1024 * 1024,
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
),
'serializer_class': UploadedFileField,
'serializer_kwargs': dict(
allowed_types=[
'image/png', 'image/jpeg', 'image/gif'
],
max_size=10 * 1024 * 1024,
)
},
'frontpage_text': {
'default': '',
@@ -1878,6 +1997,19 @@ Your {event} team"""))
"why you need information from them.")
)
},
'checkout_success_text': {
'default': '',
'type': LazyI18nString,
'serializer_class': I18nField,
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Additional success message"),
help_text=_("This message will be shown after an order has been created successfully. It will be shown in additional "
"to the default text."),
widget_kwargs={'attrs': {'rows': '2'}},
widget=I18nTextarea
)
},
'checkout_phone_helptext': {
'default': '',
'type': LazyI18nString,
@@ -2271,6 +2403,22 @@ PERSON_NAME_SCHEMES = OrderedDict([
'_scheme': 'full_transcription',
},
}),
('salutation_given_family', {
'fields': (
('salutation', pgettext_lazy('person_name', 'Salutation'), 1),
('given_name', _('Given name'), 2),
('family_name', _('Family name'), 2),
),
'concatenation': lambda d: ' '.join(
str(p) for p in (d.get(key, '') for key in ["given_name", "family_name"]) if p
),
'sample': {
'salutation': pgettext_lazy('person_name_sample', 'Mr'),
'given_name': pgettext_lazy('person_name_sample', 'John'),
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'salutation_given_family',
},
}),
('salutation_title_given_family', {
'fields': (
('salutation', pgettext_lazy('person_name', 'Salutation'), 1),

View File

@@ -82,6 +82,14 @@ class BaseDataShredder:
"""
return False
@property
def require_download_confirmation(self):
"""
Indicates whether the data of this shredder needs to be downloaded, before it is actually shredded. By default
this value is equal to the tax relevant flag.
"""
return self.tax_relevant
@property
def verbose_name(self) -> str:
"""

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