Compare commits

..

292 Commits

Author SHA1 Message Date
Raphael Michel
4caed50018 Bump version to 1.16.0 2018-06-07 18:03:54 +02:00
Raphael Michel
aadb19a792 Merge pull request #943 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-06-07 17:48:13 +02:00
Maarten van den Berg
9f8211a873 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2542 of 2542 strings)

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

powered by weblate
2018-06-07 15:19:52 +00:00
Raphael Michel
d45fc05e5d Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2542 of 2542 strings)

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

powered by weblate
2018-06-07 12:29:20 +00:00
Raphael Michel
955a3a054e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2542 of 2542 strings)

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

powered by weblate
2018-06-07 12:29:20 +00:00
Raphael Michel
60f265a5fa Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2524 of 2524 strings)

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

powered by weblate
2018-06-07 12:29:20 +00:00
Raphael Michel
a2d82a1a7b Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2524 of 2524 strings)

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

powered by weblate
2018-06-07 12:29:20 +00:00
Raphael Michel
0875d728e8 Fix PDF renderer without invoice address 2018-06-07 14:29:04 +02:00
Raphael Michel
f3cf6b8b38 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-06-06 16:14:22 +02:00
pretix translation bot
e4465cffb0 Update from Weblate. (#939) 2018-06-06 16:12:00 +02:00
Raphael Michel
ca35d714dc Translate errors for addon selection 2018-06-06 15:51:22 +02:00
Raphael Michel
c06e7348c4 Fix language of cancellation email subject 2018-06-06 15:33:31 +02:00
Raphael Michel
60ac8a6ebd Fix #935 -- Text weight 2018-06-06 15:32:01 +02:00
Raphael Michel
e3450baeb3 Fix #549 -- Multiple PDF ticket layouts (#938)
- [x] Data model
- [x] CRUD
- [x] Editor
- [x] Migration from old settings
- [x] Clone files when copying events
  - [x] badges?
- [x] Actual ticket output
- [x] Default layout on event creation
- [x] Link well from ticketing settings
- [x] Tests
- [x] Shipping plugin
  - [x] Migration
  - [x] Settings
  - [x] Create default
- [x] API
2018-06-06 15:27:55 +02:00
Raphael Michel
72661623f3 Fix #940 -- Quota caching error 2018-06-06 12:41:55 +02:00
Raphael Michel
b4d97d9432 Add signal for new OAuth applications 2018-06-05 15:47:13 +02:00
Raphael Michel
b40100f78b Merge pull request #937 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-06-05 15:44:53 +02:00
Maarten van den Berg
a343d2b42c Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (62 of 62 strings)

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

powered by weblate
2018-06-05 13:42:24 +00:00
Maarten van den Berg
d3d7e54cff Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2524 of 2524 strings)

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

powered by weblate
2018-06-05 13:39:02 +00:00
Raphael Michel
6535bc3d5e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (62 of 62 strings)

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

powered by weblate
2018-06-05 13:05:08 +00:00
Raphael Michel
f966fc8d84 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (62 of 62 strings)

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

powered by weblate
2018-06-05 13:04:52 +00:00
Raphael Michel
8a20bbd943 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2524 of 2524 strings)

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

powered by weblate
2018-06-05 13:03:07 +00:00
Raphael Michel
cd0f6d85ba Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2524 of 2524 strings)

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

powered by weblate
2018-06-05 12:55:06 +00:00
Raphael Michel
d51edbb3bb Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-06-05 14:13:46 +02:00
Raphael Michel
553e475cfb Merge pull request #930 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-06-05 14:12:44 +02:00
wallber azevedo pinheiro
b9367446d9 Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 10.9% (270 of 2470 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
wallber azevedo pinheiro
82d9fccec8 Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
anonymous
cbbcfb7a3a Translated on translate.pretix.eu (Danish)
Currently translated at 67.8% (1676 of 2470 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
Pernille Thorsen
1f862b27c1 Translated on translate.pretix.eu (Danish)
Currently translated at 67.8% (1675 of 2470 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
Lorenzo Peña
883b03349e Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
Lorenzo Peña
f740a6ba61 Translated on translate.pretix.eu (Spanish)
Currently translated at 2.9% (73 of 2470 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
Lorenzo Peña
fb3e761a37 Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
Sebastian Wallroth
3c7411328d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2470 of 2470 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
Jochem van Kessel
9c2bfdfead Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2470 of 2470 strings)

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

powered by weblate
2018-06-05 11:59:36 +00:00
Raphael Michel
4f3bd1ff4a Fix local dictionary 2018-06-05 13:59:27 +02:00
Raphael Michel
69d10489b8 Implement OAuth2 provider (#927)
- [x] Application management
  - [x] Link
  - [ ] Tests
- [x] Authorize flow
  - [x] Tests
- [x] Refresh token handling
  - [x] Tests
- [x] Revocation endpoint
  - [x] Tests
  - [x] Mitigate: https://github.com/jazzband/django-oauth-toolkit/issues/585
- [x] API authenticator / permission driver
  - [x] Test
- [x] Enforce organizer restriction
  - [x] Tests
- [x] Enforce scope restriction
  - [x] Tests
- [x] Show current applications to user
  - [x] Revoke
  - [x] Tests
- [x] Log new authorizations
  - [x] notify user
- [x] Ensure other grant types are not available
- [x] Documentation
- [x] check if revoking access toking, then refreshing gets rid of organizer constraint
- [x] Show logentry foo
2018-06-05 12:58:04 +02:00
Raphael Michel
df031b2222 Whitelist "pdf" in docs 2018-06-05 12:26:39 +02:00
Raphael Michel
850b9e5e3d Fix oversight in a95a208e 2018-06-05 11:27:31 +02:00
Raphael Michel
a95a208e1b API: Optional pdf_data field 2018-06-04 18:40:38 +02:00
Raphael Michel
50ff3628f7 Add success hook for settings form 2018-06-04 17:59:11 +02:00
Raphael Michel
14d203055b ChunkBasedFileResponse: Support Content-Length 2018-06-03 21:59:30 +02:00
Raphael Michel
4628e28592 Limit resolution of logo in PDF invoices 2018-06-02 12:37:15 +02:00
Raphael Michel
7fb3d13733 Use file.chunks() on large cached files 2018-06-02 12:16:44 +02:00
Raphael Michel
11ff81f852 Fix 85420602 and add tests 2018-06-01 13:40:08 +02:00
Raphael Michel
0f5af4b990 Automatically shorten event name on invoice 2018-06-01 13:32:47 +02:00
Raphael Michel
85420602e8 Fix #54 -- Allow the admin to force accept payments 2018-06-01 13:25:07 +02:00
Raphael Michel
6ccf55b601 Fix settings form validation 2018-06-01 13:21:13 +02:00
Raphael Michel
42c9e21d04 Refs #654 -- API call to mark order as refunded 2018-06-01 10:38:34 +02:00
Raphael Michel
3030c300f2 Fix order change form with required field 2018-05-31 12:57:06 +02:00
Raphael Michel
48b969f3c3 Refs #928 -- Show ticket secret in order change form 2018-05-31 12:57:06 +02:00
Raphael Michel
bbb78aa5e6 Refs #928 -- Allow to regenerate secrets of specific tickets 2018-05-31 12:57:06 +02:00
Raphael Michel
31380bbef2 Fix #928 -- Allow searching for ticket secrets 2018-05-31 12:57:06 +02:00
Mason Mohkami
479a7d9162 Fix #357 -- Implement go to for vouchers (#849)
* Add Go input for vouchers on the vouchers list page (#357)

* Final fixes
2018-05-31 12:43:32 +02:00
Felix Rindt
6fe02f156a Fix #898 -- Add setting to configure subevent ordering on frontpage (#906)
Fixes #898.
2018-05-31 12:28:44 +02:00
Raphael Michel
c4ed210fed Fix #932 -- Fix celery dependency 2018-05-30 11:35:11 +02:00
Raphael Michel
ae686fab38 Set payment_date for paid orders created via API 2018-05-30 11:34:59 +02:00
Raphael Michel
8edca9ed5d Fix missing attribute in docs 2018-05-30 11:34:23 +02:00
Raphael Michel
05bafd0db5 Enable Dutch 2018-05-29 10:39:45 +02:00
pretix translation bot
341d699240 Update from Weblate. (#912) 2018-05-29 10:13:51 +02:00
Raphael Michel
552093d962 Extend wordlist 2018-05-28 18:20:35 +02:00
Raphael Michel
eb6063cc2d Add QR codes for pseudonymization ID 2018-05-28 17:02:56 +02:00
Raphael Michel
550ff4ff18 Ref #66 -- Fix more crashes related to disabled payment providers 2018-05-28 16:49:28 +02:00
Raphael Michel
5383a8b77c Fix custom taxation without invoice addresses 2018-05-28 16:23:34 +02:00
Raphael Michel
86117091fe Refs #66 -- Fix crash when payment provider plugin is disabled 2018-05-28 16:17:32 +02:00
Raphael Michel
b113028a5f Fix exception in CSV import 2018-05-28 16:17:32 +02:00
Raphael Michel
60a3f21857 Fix error in voucher CSV export 2018-05-28 16:17:32 +02:00
Felix Rindt
65a2ea3935 Fix #922 -- make widget compat mode not required (#926)
Fixes #922
2018-05-28 15:03:42 +02:00
Raphael Michel
6ecddfc6c0 Automatically re-render PDF for files lost due to bug 2018-05-28 11:44:15 +02:00
Raphael Michel
d65d48db48 Fix accidental deletion of invoices 2018-05-28 11:44:15 +02:00
Felix Rindt
f509b26800 Mark product change panel titles for translation (#918) 2018-05-28 10:54:35 +02:00
Raphael Michel
43fb6fe6e5 Fix MySQL package 2018-05-28 08:27:06 +02:00
Raphael Michel
9d2d8684b6 Fix widget test 2018-05-27 12:03:06 +02:00
Jakob Schnell
1689925508 Fix #707 -- Setup automated spell-checking for translations (#896)
This will:
  * set up potypo
  * add wordlists, edgecases and phrases
  * fix english typos across the codebase
  * fix german typos and translation
2018-05-27 11:59:10 +02:00
Raphael Michel
4d249553bf Fix setup.py 2018-05-26 13:44:56 +02:00
Raphael Michel
43ea1044cd Upgrade kombu 2018-05-26 13:08:56 +02:00
Raphael Michel
cc4a301dc1 Pin celery version 2018-05-26 12:55:58 +02:00
Felix Rindt
ab67eea36e Fix bug in date/time question stats (#916)
Fix bug in date/time question stats
2018-05-18 22:51:11 +02:00
Raphael Michel
fa326eba6f Introduce original price (#905)
* Introduce original price

* Rebase and styling

* Widget
2018-05-18 22:48:38 +02:00
Raphael Michel
c30ebdf287 Fix test on PostgreSQL 2018-05-18 13:56:37 +02:00
Raphael Michel
835bcb7207 Add add-ons to pretixdroid API 2018-05-18 12:15:32 +02:00
Raphael Michel
777424ad18 Remove debugging output 2018-05-18 11:54:42 +02:00
Raphael Michel
4985e7e96d Fix X-Page-Generated for paginated results 2018-05-18 11:31:37 +02:00
Raphael Michel
ca1e64ec10 Fix typos 2018-05-17 20:27:26 +02:00
Raphael Michel
26029508c6 Implement Last-Modified for a number of API resources 2018-05-17 16:09:08 +02:00
Raphael Michel
118259a96b Add permission test for creating orders 2018-05-16 12:23:17 +02:00
Raphael Michel
35e8dcf2bc Fix #599 -- Add API to create orders (#911)
* [WIP] Fix #599 -- Add API to create orders

* Add more validation logic

* Add docs and some validation

* Fix test on MySQl

* Validation is fun, let's do more of it!

* Fix live_issues
2018-05-16 12:14:31 +02:00
Mikkel Ricky
359a5d01e6 Fix #908 -- Fix display of ticket download message (#910) 2018-05-14 14:34:50 +02:00
Raphael Michel
1c2acbb57f Add last_modified property to orders (#907) 2018-05-14 11:09:26 +02:00
Raphael Michel
01a702c529 Fix typo 2018-05-13 18:19:10 +02:00
Raphael Michel
1ee584c5a1 Fix #903 -- Incorrect price calculation for variations 2018-05-11 14:33:23 +02:00
Raphael Michel
fc10bd7749 Merge branch 'master' of github.com:pretix/pretix 2018-05-11 14:28:14 +02:00
Raphael Michel
f2568092a7 Fix order overview error 2018-05-11 14:27:51 +02:00
Felix Rindt
6b5d5a6334 Add subevent bulk create button when no exist (#904) 2018-05-11 14:19:59 +02:00
Raphael Michel
195ed57025 Voucher redemption: Markup improvements 2018-05-11 13:59:06 +02:00
Raphael Michel
008b4a134b Allow to require invoice name only 2018-05-11 12:58:14 +02:00
robbi5
1b9bfb5b62 Add badge plugin support to MANIFEST.in (#902) 2018-05-11 12:54:05 +02:00
Raphael Michel
edeaa1333b Fix #473 -- Internal name for categories and products (#900)
* Fix #473 -- Internal name for categories and products

* fix pdf renderer
2018-05-11 12:53:25 +02:00
Raphael Michel
e678b52a7e Open addon panels by default 2018-05-10 23:30:46 +02:00
Raphael Michel
b549db58e4 Fix a test case 2018-05-10 12:58:11 +02:00
Raphael Michel
c14059f66a Merge pull request #901 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-05-10 12:14:55 +02:00
Raphael Michel
11f69daaec Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2470 of 2470 strings)

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

powered by weblate
2018-05-10 10:14:28 +00:00
Raphael Michel
c0120c0f17 Translated on translate.pretix.eu (German (informal))
Currently translated at 99.8% (2467 of 2470 strings)

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

powered by weblate
2018-05-10 10:14:11 +00:00
Raphael Michel
c1a5f9adf1 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2470 of 2470 strings)

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

powered by weblate
2018-05-10 10:13:13 +00:00
Raphael Michel
5087f27546 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2470 of 2470 strings)

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

powered by weblate
2018-05-10 10:11:57 +00:00
Raphael Michel
efbff9e217 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-05-10 12:07:44 +02:00
Raphael Michel
20ea83ae93 Merge pull request #892 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-05-10 12:07:20 +02:00
Raphael Michel
05daeb561c Use <details> und <summary> instead of panel-collapse 2018-05-10 12:04:29 +02:00
Raphael Michel
bfff001752 Use <details> and <summary> for variations 2018-05-10 11:14:13 +02:00
Raphael Michel
c3a45a1584 Do not show end time if not set 2018-05-10 10:24:12 +02:00
Raphael Michel
b09a92a264 More accessibility improvements 2018-05-10 10:24:12 +02:00
Raphael Michel
44a792583c Specifically warn about some shredders 2018-05-10 10:24:12 +02:00
Raphael Michel
71c8267dea Improve screenreader accessibility 2018-05-10 10:24:12 +02:00
Mikkel Ricky
b6688f56b5 Translated on translate.pretix.eu (Danish)
Currently translated at 67.8% (1666 of 2454 strings)

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

powered by weblate
2018-05-09 14:07:44 +00:00
Raphael Michel
f703164098 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2454 of 2454 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Pernille Thorsen
6a6b27e905 Translated on translate.pretix.eu (Danish)
Currently translated at 67.8% (1666 of 2454 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Pieter Roziers
731a46c612 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Maarten Visscher
92a8078322 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Maarten van den Berg
ba2d77f0bb Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Pieter Roziers
3d21c15281 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Maarten van den Berg
cb4b20c057 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Maarten van den Berg
2af2767699 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
etiontdn
e4bb19b98a Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 9.6% (237 of 2454 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Maarten Visscher
7e784c9509 Translated on translate.pretix.eu (Dutch)
Currently translated at 39.9% (957 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Maarten van den Berg
3dd27797dc Translated on translate.pretix.eu (Dutch)
Currently translated at 39.7% (951 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Maarten van den Berg
5e059272dc Translated on translate.pretix.eu (Dutch)
Currently translated at 39.5% (947 of 2393 strings)

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

powered by weblate
2018-05-09 09:13:41 +00:00
Raphael Michel
0a9aeca3bc Bulk deletion for subevents 2018-05-09 11:13:34 +02:00
Raphael Michel
11d42e0f93 Fix failing test case 2018-05-09 11:13:34 +02:00
Raphael Michel
85d8658037 Merge pull request #897 from felixrindt/emailhelptext
Presale: change email field help text
2018-05-09 10:59:38 +02:00
Raphael Michel
dfa29950ef Fix #899 -- Docker container: Set gunicorn workers to two times the CPU count 2018-05-09 10:57:59 +02:00
Raphael Michel
b7366a8704 Allow to filter subevent list by weekday 2018-05-09 09:59:39 +02:00
Felix Rindt
57416103c3 change email help text 2018-05-07 11:35:08 +02:00
Raphael Michel
72bd3731de Fix iTunes URL 2018-05-07 09:10:21 +02:00
Raphael Michel
9fab20ca6c Log confirm message consent 2018-05-04 15:31:56 +02:00
Raphael Michel
8b4453f32d Add help text to can_change_organizer_settings 2018-05-04 15:31:43 +02:00
Raphael Michel
f4b77e6b03 Discourage long event short forms 2018-05-04 10:58:19 +02:00
Raphael Michel
c3da2fca9b Fix placeholder for event deletion password (#893)
fix placeholder for event deletion password
2018-05-03 11:25:31 +02:00
luto
c0d68c5740 fix placeholder for event deletion password 2018-05-03 11:22:50 +02:00
Raphael Michel
5398564aec Bump version to 1.16.0.dev0 2018-05-03 09:56:51 +02:00
Raphael Michel
904dc80aab Bump version to 1.15.0 2018-05-03 09:56:08 +02:00
Raphael Michel
516de20148 Merge pull request #891 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-05-02 17:39:22 +02:00
Raphael Michel
be088709af Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2454 of 2454 strings)

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

powered by weblate
2018-05-02 15:38:48 +00:00
Maarten van den Berg
fd4f5057b3 Translated on translate.pretix.eu (Dutch)
Currently translated at 39.4% (944 of 2393 strings)

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

powered by weblate
2018-05-02 15:38:48 +00:00
Raphael Michel
686d5e8b03 Merge pull request #890 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-05-02 17:38:40 +02:00
Raphael Michel
c371ff5504 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2454 of 2454 strings)

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

powered by weblate
2018-05-02 15:37:10 +00:00
Raphael Michel
9862dca4aa Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2454 of 2454 strings)

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

powered by weblate
2018-05-02 15:15:30 +00:00
Maarten van den Berg
716321b37b Translated on translate.pretix.eu (Dutch)
Currently translated at 39.0% (934 of 2393 strings)

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

powered by weblate
2018-05-02 14:01:36 +00:00
Raphael Michel
b3ed8bad9c Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-05-02 16:01:29 +02:00
Raphael Michel
0a170f5c29 Docs: Fix inconsistency 2018-05-02 16:00:54 +02:00
Raphael Michel
ec0fba7913 Merge pull request #880 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-05-02 16:00:48 +02:00
Maarten van den Berg
2630c2baf1 Translated on translate.pretix.eu (Dutch)
Currently translated at 38.9% (932 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Maarten van den Berg
a01865b19b Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Pernille Thorsen
00f6115a93 Translated on translate.pretix.eu (Danish)
Currently translated at 69.6% (1666 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Maarten Visscher
a466202bac Translated on translate.pretix.eu (Dutch)
Currently translated at 37.3% (893 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Maarten Visscher
bb12ef24f8 Translated on translate.pretix.eu (Dutch)
Currently translated at 37.1% (888 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Maarten Visscher
c862c6de0f Translated on translate.pretix.eu (German)
Currently translated at 99.9% (2392 of 2393 strings)

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

powered by weblate

fix https://github.com/pretix/pretix/issues/879
2018-05-02 14:00:11 +00:00
Maarten Visscher
a1cb3ec8d5 Translated on translate.pretix.eu (French)
Currently translated at 86.9% (2081 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Maarten Visscher
187d4cd02d Translated on translate.pretix.eu (Dutch)
Currently translated at 35.7% (855 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Pernille Thorsen
20a0f9b026 Translated on translate.pretix.eu (Danish)
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Mikkel Ricky
f9c0ed6ad4 Translated on translate.pretix.eu (Danish)
Currently translated at 69.5% (1665 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Pernille Thorsen
27652e7191 Translated on translate.pretix.eu (Danish)
Currently translated at 69.5% (1665 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Pernille Thorsen
828665cb29 Translated on translate.pretix.eu (Danish)
Currently translated at 69.2% (1658 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Mikkel Ricky
fa784c83bf Translated on translate.pretix.eu (Danish)
Currently translated at 69.1% (1655 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Pernille Thorsen
2d83176892 Translated on translate.pretix.eu (Danish)
Currently translated at 69.1% (1654 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Pernille Thorsen
776758c7e8 Translated on translate.pretix.eu (Danish)
Currently translated at 68.9% (1650 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Mikkel Ricky
d72afe9b92 Translated on translate.pretix.eu (Danish)
Currently translated at 68.7% (1645 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Pernille Thorsen
dee61b5499 Translated on translate.pretix.eu (Danish)
Currently translated at 68.7% (1644 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Mikkel Ricky
9f73d0a7fb Translated on translate.pretix.eu (Danish)
Currently translated at 68.6% (1643 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Pernille Thorsen
bc804c9e56 Translated on translate.pretix.eu (Danish)
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Felix Rindt
35f450aee7 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate

refs https://github.com/pretix/pretix/issues/879
2018-05-02 14:00:11 +00:00
Felix Rindt
5803b4ca27 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-05-02 14:00:11 +00:00
Raphael Michel
7bccd62a4f Fix #678 -- Data shredders for personally identifiable information (#817)
* Add data shredders for PII

* First working shredder

* Add more shredders

* Add new shredders and download confirmation

* tmp

* PayPal, Stripe, banktransfer

* Add icon to logs

* Untested payment log shredders

* Add waiting list shredder

* First tests

* Add tests for shredders

* Improve templats, link to shredder

* Test payment info shredders

* More tests

* Documentation

* Fix enabled flag in payment provider overview

* Fix minor issues
2018-05-02 15:59:59 +02:00
Raphael Michel
335838f2b2 Fix typo in class name 2018-05-02 13:28:32 +02:00
Raphael Michel
204d8cc7eb Fix git hook 2018-05-02 10:34:50 +02:00
Raphael Michel
61f5d4b172 Docs: Change git hook to only look in changed files 2018-05-02 09:56:33 +02:00
Raphael Michel
3d829c6ce8 Fix tests for paypal webhook 2018-05-02 09:51:44 +02:00
Raphael Michel
5d9852b72c Fix paypal webhook receiver 2018-05-01 19:20:07 +02:00
Raphael Michel
f561ece9d1 Fix #887 -- Absolute URL for images in widget 2018-04-30 10:27:24 +02:00
Raphael Michel
66eabd3bd6 Fix PlaceholderValidator to catch placehodlers with invalid characters 2018-04-29 14:29:40 +02:00
Raphael Michel
b2f92acbf6 Refs #654 -- API: Writable invoice operations (#886)
* Invoices

* Update invoices.rst
2018-04-29 14:29:03 +02:00
Raphael Michel
6f30ecb365 Refs #654 -- Writable API methods for waiting list entries (#885)
* Refs #654 -- Writable API methods for waiting list entries

* Update test_waitinglist.py
2018-04-29 14:28:32 +02:00
Raphael Michel
32a89d3895 Stripe: Fix another statement error 2018-04-27 12:19:48 +02:00
Raphael Michel
97bf958b74 Allow to re-auth by using the U2F token 2018-04-26 20:24:03 +02:00
Raphael Michel
30f8afca85 Fix logout on reauth page 2018-04-26 19:31:14 +02:00
Raphael Michel
ed88a8e3e3 Bump version to 1.15.0.dev0 (very late) 2018-04-26 14:17:51 +02:00
Raphael Michel
421f690f42 Add test for cascading of cart item removal 2018-04-26 14:14:00 +02:00
Raphael Michel
a330e8afb2 Fix incorrect button CSS usage 2018-04-26 09:11:56 +02:00
Raphael Michel
d8e5c9f033 API: Fix insufficient permission check 2018-04-26 09:11:33 +02:00
Raphael Michel
209646e012 Remove color scheme test pages 2018-04-25 18:28:32 +02:00
Raphael Michel
7d518df13c Limit all stripe statement_descriptors to 22 characters 2018-04-25 18:17:37 +02:00
Raphael Michel
ca603f41db New color scheme and UI design 2018-04-25 17:13:20 +02:00
Ture Gjørup
7bb18f6fad Refs #654 -- API: Writable event endpoints (#756)
* MKBDIGI-185: Added update/create to events

* MKBDIGI-185: Added validation for 'slug, 'live' on event endpoint

* MKBDIGI-185: Code formatting

* MKBDIGI-185: Added 'plugins' to 'event' endpoint

* MKBDIGI-185: Merge migrations

* MKBDIGI-185: Cleaned up static methods

* EBILL-5: Added delete endpoint for event

* EBILL-5: Merge migrations

* EBILL-5: Fixed imports

* EBILL-5: Changed plugins to only list plugins enabled for the event

* EBILL-5: Added clone event endpoint

* EBILL-5: Removed permissions check API test for events

* EBILL-5: Merged master, updated migrations

* EBILL-5: Updated api permissions check for CRUD on events

* EBILL-5: Removed 'unique_together' constraint on event model

* EBILL-5: Removed call to changed static methods in test

* EBILL-5: Changed Event 'has_paid_things'  to a property for consistency

* EBILL-5: Fixed created response code in documentation

* EBILL-6: Documentation fixes

* EBILL-6: Fixed typo

* EBILL-6: Fixed permissions

* EBILL-6: Added note on copying settings to documentation

* EBILL-6: Created model method for deleting sub objects on event before delete

* EBILL-6: Fixed typo

* EBILL-6: Re-added meta_data as read-only

* EBILL-6: Fixed permissions test

* EBILL-6: Added plugins issues check before live. Moved issues property from form to Event model.

* EBILL-6: Upped version number in documentation

* Add write support for MetaDataField

* EBILL-6: Expanded documentation for the clone endpoint, made behaviour of 'is_public' similar to 'plugins' for consistency

* EBILL-6: Re-added EventCRUDPermission

* EBILL-16: Updated documentation with permission model for the API

* EBILL-16: Added 'has_subevents' validation to ensure it cannot be changed once event is created.

* EBILL-16: Fixed event clone not differentiating between "not set" and "deliberately set to False"

* EBILL-16: Fixed event live validation

* EBILL-16: Added logging of live activated/deactivated

* EBILL-16: Fixed create event bug when no 'meta_data' supplied

* EBILL-16: Typo fixed

* EBILL-16: Added log display for "event created"

* EBILL-16: Enabling a plugin now calls 'installed' if applicable and log entries are added

* EBILL-16: Updated tests for events

* Do not allow enabling restricted plugins via the API

* Remove unused code
2018-04-25 17:13:09 +02:00
Raphael Michel
1a0e2031d2 Add check-in capabilities to official RESTful API (#884)
* Add check-in capabilities to official RESTful API

* Add deprecation note
2018-04-25 16:02:07 +02:00
Raphael Michel
4f83d69205 Remove migration code for legacy session data 2018-04-25 13:23:11 +02:00
Raphael Michel
cfafd90f15 API: Remove deprecated fields on order resource 2018-04-25 13:22:21 +02:00
Raphael Michel
a94f416b3c Refactor check-in logic into core 2018-04-25 13:22:06 +02:00
Raphael Michel
fd47e2de29 Add more entropy to cart IDs and bind them to session IDs 2018-04-25 08:50:15 +02:00
Raphael Michel
abbc403f73 Stripe: Fix Bancontact payments 2018-04-25 08:46:33 +02:00
Raphael Michel
b41c536865 API: Add status view to checkin list resource 2018-04-24 19:08:15 +02:00
Raphael Michel
bee7314dd7 API: Add filters to questions view 2018-04-24 18:33:57 +02:00
Raphael Michel
d25407e3b4 API: Add fuzzy search to order positions API 2018-04-24 18:27:18 +02:00
Raphael Michel
ad697369ef API: Add list and case-insensitive filters to order(positions) resource 2018-04-24 18:25:51 +02:00
Raphael Michel
edbdb17a2f Fix #850 -- Admission time should be allowed to be before event start 2018-04-24 17:46:58 +02:00
Felix Rindt
9d2e2a1ea2 Fix #881 -- dont redirect tel scheme (#883) 2018-04-24 17:20:47 +02:00
Tobias Kunze
6df0597c5e Fix #881 -- Allow tel: links in markdown (#882)
Closes #881
2018-04-24 16:22:53 +02:00
Raphael Michel
093eb28463 Badges: Respect admin sessions 2018-04-23 18:29:50 +02:00
Raphael Michel
7d0c279f5b Merge pull request #876 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-04-23 12:04:06 +02:00
Raphael Michel
d98a6a09bb Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-04-23 10:03:43 +00:00
Raphael Michel
02cf7b9d66 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2393 of 2393 strings)

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

powered by weblate
2018-04-23 10:01:36 +00:00
Raphael Michel
9253b783dd Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-04-23 11:53:38 +02:00
Raphael Michel
2ed82be809 Merge pull request #875 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-04-23 11:53:15 +02:00
Raphael Michel
1c1499dec8 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2369 of 2369 strings)

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

powered by weblate
2018-04-23 09:52:13 +00:00
Raphael Michel
f7f151d2a9 Fix #767 -- Allow to obtain the list of orders for a question answer 2018-04-23 11:51:28 +02:00
Raphael Michel
13f29ee3ce Fix file upload when mdoifying questions in backend 2018-04-23 11:23:01 +02:00
Raphael Michel
ce68f52ca0 Add badge printing capabilities (#868)
Add badge printing capabilities
2018-04-22 12:02:51 +02:00
Raphael Michel
33172767a6 Add subevent information to invoices 2018-04-20 12:22:39 +02:00
Raphael Michel
649b3839d2 Merge pull request #874 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-04-19 18:17:08 +02:00
Raphael Michel
666fb4c194 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-04-19 16:16:06 +00:00
Raphael Michel
9301497a4a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2369 of 2369 strings)

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

powered by weblate
2018-04-19 16:15:49 +00:00
Raphael Michel
6956e21caf Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-04-19 16:15:13 +00:00
Raphael Michel
71dec5746e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2369 of 2369 strings)

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

powered by weblate
2018-04-19 16:14:27 +00:00
Raphael Michel
0ea8f4c259 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-04-19 18:10:27 +02:00
Raphael Michel
8602814dc3 Merge pull request #869 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-04-19 18:09:31 +02:00
Raphael Michel
20e60edbc6 Added translation on translate.pretix.eu (Czech) 2018-04-19 16:09:02 +00:00
Raphael Michel
88f59ad1eb Added translation on translate.pretix.eu (Czech) 2018-04-19 16:09:02 +00:00
Mikkel Ricky
668a899260 Translated on translate.pretix.eu (Danish)
Currently translated at 69.0% (1635 of 2368 strings)

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

powered by weblate
2018-04-19 16:09:02 +00:00
Maarten Visscher
75ae85a5d4 Translated on translate.pretix.eu (Dutch)
Currently translated at 35.3% (836 of 2368 strings)

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

powered by weblate
2018-04-19 16:09:02 +00:00
Maarten van den Berg
abc1b4e1b2 Translated on translate.pretix.eu (Dutch)
Currently translated at 34.7% (822 of 2368 strings)

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

powered by weblate
2018-04-19 16:09:02 +00:00
Maarten Visscher
fa194f0cef Translated on translate.pretix.eu (Dutch)
Currently translated at 34.6% (821 of 2368 strings)

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

powered by weblate
2018-04-19 16:09:02 +00:00
Maarten van den Berg
b3fbd89456 Translated on translate.pretix.eu (Dutch)
Currently translated at 34.6% (820 of 2368 strings)

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

powered by weblate
2018-04-19 16:09:02 +00:00
N Eliseo S Carranza
5334a4cbe0 Translated on translate.pretix.eu (Spanish)
Currently translated at 0.2% (7 of 2368 strings)

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

powered by weblate
2018-04-19 16:09:02 +00:00
Raphael Michel
8f2adf0a50 Preselect product if only one is selectable 2018-04-19 18:06:57 +02:00
Raphael Michel
62dfd7cef0 Change link text of footer backlinks 2018-04-19 12:50:33 +02:00
Raphael Michel
a8321e8cd3 Fix invalid voucher form submission 2018-04-19 10:23:54 +02:00
Felix Rindt
0119552336 move footer to container (#872) 2018-04-19 10:06:03 +02:00
Jakob Schnell
033abc64c8 fix translation documentation link (#873) 2018-04-19 10:05:14 +02:00
Raphael Michel
ef8014bc6d Fix initial value in voucher form 2018-04-18 15:50:41 +02:00
Raphael Michel
96a880b5ae Warn more strongly about Stripe Sofort 2018-04-18 14:39:35 +02:00
Raphael Michel
bfedcde978 Fix #852 -- Stripe: Set statement_descriptor on all payment methods 2018-04-18 14:36:43 +02:00
Tobias Kunze
badad70984 Remove duplicate robots.txt line (#870) 2018-04-17 10:20:26 +02:00
Raphael Michel
7611188535 Localize date-based subevent search 2018-04-16 17:19:17 +02:00
Raphael Michel
31f2cc1fdc Fix LOGIN_URL setting 2018-04-16 15:26:38 +02:00
Raphael Michel
187e646fa0 Fix tests and style for last commit 2018-04-13 10:21:49 +02:00
Raphael Michel
b2721db8e0 Refs #634 -- Re-allow deleting the last subevent and fix UI crashes 2018-04-13 10:14:54 +02:00
Raphael Michel
fd9f521c60 Merge pull request #866 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-04-13 10:01:43 +02:00
Raphael Michel
edd6fbe35f Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2368 of 2368 strings)

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

powered by weblate
2018-04-13 08:00:45 +00:00
Raphael Michel
839c9c9884 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2368 of 2368 strings)

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

powered by weblate
2018-04-13 07:59:35 +00:00
Raphael Michel
3a1fe992d6 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-04-13 09:56:03 +02:00
Raphael Michel
9899f6d1f8 Merge pull request #865 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-04-13 09:53:47 +02:00
Mikkel Ricky
446a464b3d Translated on translate.pretix.eu (Danish)
Currently translated at 69.0% (1632 of 2362 strings)

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

powered by weblate
2018-04-12 15:31:54 +00:00
Raphael Michel
6a347799c7 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2362 of 2362 strings)

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

powered by weblate
2018-04-12 15:31:54 +00:00
Raphael Michel
2dae89e41c Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2362 of 2362 strings)

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

powered by weblate
2018-04-12 15:31:54 +00:00
Raphael Michel
e8119ba80d Merge branch 'master' of github.com:pretix/pretix 2018-04-12 17:29:49 +02:00
Raphael Michel
a5ecad8fae Fix required field 2018-04-12 17:29:40 +02:00
Raphael Michel
1708a4c831 Fix error for reseller module 2018-04-12 15:04:29 +02:00
Raphael Michel
a237078b68 Optional link back to organizer page 2018-04-12 13:58:10 +02:00
Raphael Michel
4ef63d026e Stripe and PayPal: Issue warning on payments for paid orders 2018-04-12 12:55:15 +02:00
Raphael Michel
b8ae3cdd3f Improve sample for event_end 2018-04-12 12:46:24 +02:00
Raphael Michel
46d855ce0f Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-04-12 12:42:06 +02:00
Raphael Michel
a3306bbb5a Update from Weblate. (#856)
Update from Weblate.
2018-04-12 12:41:42 +02:00
Claude
1428a5e7e2 Translated on translate.pretix.eu (French)
Currently translated at 88.3% (2083 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:14 +00:00
Maarten van den Berg
427940b3be Translated on translate.pretix.eu (Dutch)
Currently translated at 33.7% (795 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Pieter Roziers
7a3e7dc631 Translated on translate.pretix.eu (Dutch)
Currently translated at 30.3% (716 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Mikkel Ricky
b38bb40a5d Translated on translate.pretix.eu (Danish)
Currently translated at 69.0% (1627 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Pieter Roziers
b2e1e2e89a Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Mikkel Ricky
02fcc42395 Translated on translate.pretix.eu (Danish)
Currently translated at 100.0% (60 of 60 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Ture Gjørup
3accc406a7 Translated on translate.pretix.eu (Danish)
Currently translated at 68.0% (1604 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Mikkel Ricky
1c238b7ce4 Translated on translate.pretix.eu (Danish)
Currently translated at 68.0% (1603 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Raphael Michel
45770173c4 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2357 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Raphael Michel
3e5f6abdad Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2357 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Raphael Michel
4117c6127e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2357 of 2357 strings)

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

powered by weblate
2018-04-12 10:41:13 +00:00
Raphael Michel
aae1fad7ab Add event end time as a ticket PDF variable 2018-04-12 12:33:14 +02:00
Raphael Michel
ada65b5ce2 Add order locale to CSV order export 2018-04-12 12:29:59 +02:00
Raphael Michel
14c0c65e17 Fix double shown email address in confirm view 2018-04-12 12:25:04 +02:00
Raphael Michel
0201aa9bd1 Fix signal documentation 2018-04-12 12:24:32 +02:00
Raphael Michel
dca530f2f2 Fix #860 -- Workaround for SQLite 3.23.0 2018-04-12 10:30:04 +02:00
Raphael Michel
c9f9668e52 PayPal: Fix support for CLP 2018-04-11 13:03:01 +02:00
Raphael Michel
4f636b7cfb Fix wrong attribute usage in SubEventBulkCreate 2018-04-10 18:32:16 +02:00
Raphael Michel
34a04c0059 Fix #860 -- Compatibility with SQLite 3.23.0 2018-04-10 12:21:39 +02:00
Raphael Michel
00ee58d3fd Refs #860 -- Do not create objets in setUpTestData that are later mutated 2018-04-10 11:23:08 +02:00
Jan Felix Wiebe
ecb3c4f4f3 Fix #861 -- Add event name to admin notification email (#862) 2018-04-10 09:36:29 +02:00
Raphael Michel
9dace592c0 Refs #787 -- Activate 2FA after adding a device by default 2018-04-09 18:48:00 +02:00
Raphael Michel
5d73221b06 Add more flexibility to ReportLabMixin 2018-04-09 14:53:19 +02:00
Raphael Michel
d50958c9ee Not sure why this works locally 2018-04-09 10:58:28 +02:00
Raphael Michel
52bb005792 Fix all wrong static URLs 2018-04-09 10:44:51 +02:00
Raphael Michel
3121aa7164 Fix incorrect worker URL 2018-04-09 10:12:14 +02:00
Raphael Michel
87c54f07c6 Move PDF editor out of plugin and into core 2018-04-09 09:40:18 +02:00
Raphael Michel
f1d4a686b1 Add a default ordering for quotas 2018-04-08 19:14:38 +02:00
Raphael Michel
56ac037128 Fix incorrect ticket PDF placeholders 2018-04-08 16:31:44 +02:00
Raphael Michel
e977045d5f Clear cart session if all products are removed manually 2018-04-06 10:06:11 +02:00
Raphael Michel
3301b106ab Add fee type "gift card" 2018-04-06 10:06:04 +02:00
Raphael Michel
e645f55191 Hide Enable button for restricted plugins without staff session 2018-04-05 16:20:20 +02:00
Raphael Michel
278d25c803 Thumbnails: Fix PNG alpha 2018-04-05 09:28:31 +02:00
337 changed files with 63106 additions and 25170 deletions

28
.gitattributes vendored
View File

@@ -1,17 +1,17 @@
src/static/fontawesome/* linguist-vendored
src/static/lightbox/* linguist-vendored
src/static/typeahead/* linguist-vendored
src/static/moment/* linguist-vendored
src/static/datetimepicker/* linguist-vendored
src/static/colorpicker/* linguist-vendored
src/static/fileupload/* linguist-vendored
src/static/vuejs/* linguist-vendored
src/static/select2/* linguist-vendored
src/static/charts/* linguist-vendored
src/static/rrule/* linguist-vendored
src/static/iframeresizer/* linguist-vendored
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/fabric.* linguist-vendored
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/pdf.* linguist-vendored
src/pretix/static/fontawesome/* linguist-vendored
src/pretix/static/lightbox/* linguist-vendored
src/pretix/static/typeahead/* linguist-vendored
src/pretix/static/moment/* linguist-vendored
src/pretix/static/datetimepicker/* linguist-vendored
src/pretix/static/colorpicker/* linguist-vendored
src/pretix/static/fileupload/* linguist-vendored
src/pretix/static/vuejs/* linguist-vendored
src/pretix/static/select2/* linguist-vendored
src/pretix/static/charts/* linguist-vendored
src/pretix/static/rrule/* linguist-vendored
src/pretix/static/iframeresizer/* linguist-vendored
src/pretix/static/pdfjs/* linguist-vendored
src/pretix/static/fabric/* linguist-vendored
# Denote all files that are truly binary and should not be modified.
*.eot binary

View File

@@ -25,7 +25,7 @@ if [ "$1" == "doctests" ]; then
cd doc
make doctest
fi
if [ "$1" == "spelling" ]; then
if [ "$1" == "doc-spelling" ]; then
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
cd doc
make spelling
@@ -33,12 +33,17 @@ if [ "$1" == "spelling" ]; then
exit 1
fi
fi
if [ "$1" == "translation-spelling" ]; then
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements/dev.txt
cd src
potypo
fi
if [ "$1" == "tests" ]; then
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt pytest-xdist
cd src
python manage.py check
make all compress
py.test --reruns 5 tests
py.test --reruns 5 -n 2 tests
fi
if [ "$1" == "tests-cov" ]; then
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt

View File

@@ -15,7 +15,7 @@ matrix:
- python: 3.6
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
- python: 3.6
env: JOB=tests-cov
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.6
env: JOB=style
- python: 3.4
@@ -37,12 +37,16 @@ matrix:
- python: 3.6
env: JOB=plugins
- python: 3.6
env: JOB=spelling
env: JOB=doc-spelling
- python: 3.6
env: JOB=translation-spelling
addons:
postgresql: "9.4"
apt:
packages:
- enchant
- myspell-de-de
- aspell-en
branches:
except:
- /^weblate-.*/

View File

@@ -3,7 +3,7 @@ FROM python:3.6
RUN apt-get update && \
apt-get install -y git libxml2-dev libxslt1-dev python-dev python-virtualenv locales \
libffi-dev build-essential python3-dev zlib1g-dev libssl-dev gettext libpq-dev \
libmysqlclient-dev libmemcached-dev libjpeg-dev supervisor nginx sudo \
default-libmysqlclient-dev libmemcached-dev libjpeg-dev supervisor nginx sudo \
--no-install-recommends && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \

View File

@@ -3,7 +3,7 @@ cd /pretix/src
export DJANGO_SETTINGS_MODULE=production_settings
export DATA_DIR=/data/
export HOME=/pretix
NUM_WORKERS=10
export NUM_WORKERS=$((2 * $(nproc --all)))
if [ ! -d /data/logs ]; then
mkdir /data/logs;

View File

@@ -6,27 +6,13 @@ with pretix' REST API, such as authentication, pagination and similar definition
.. _`rest-auth`:
Obtaining an API token
----------------------
To authenticate your API requests, you need to obtain an API token. You can create a
token in the pretix web interface on the level of organizer teams. Create a new team
or choose an existing team that has the level of permissions the token should have and
create a new token using the form below the list of team members:
.. image:: img/token_form.png
:class: screenshot
You can enter a description for the token to distinguish from other tokens later on.
Once you click "Add", you will be provided with an API token in the success message.
Copy this token, as you won't be able to retrieve it again.
.. image:: img/token_success.png
:class: screenshot
Authentication
--------------
If you're building an application for end users, we strongly recommend that you use our
:ref:`OAuth-based authentication progress <rest-oauth>`. However, for simpler needs, you
can also go with static API tokens that you can create on a per-team basis (see below).
You need to include the API token with every request to pretix' API in the ``Authorization`` header
like the following:
@@ -44,6 +30,43 @@ like the following:
adding OAuth2 support in the future for user-level authentication. If you want
to use session authentication, be sure to comply with Django's `CSRF policies`_.
Obtaining an API token
----------------------
To authenticate your API requests, you need to obtain an API token. You can create a
token in the pretix web interface on the level of organizer teams. Create a new team
or choose an existing team that has the level of permissions the token should have and
create a new token using the form below the list of team members:
.. image:: img/token_form.png
:class: screenshot
You can enter a description for the token to distinguish from other tokens later on.
Once you click "Add", you will be provided with an API token in the success message.
Copy this token, as you won't be able to retrieve it again.
.. image:: img/token_success.png
:class: screenshot
Permissions
-----------
The API follows pretix team based permissions model. Each organizer can have several teams
each with it's own set of permissions. Each team can have any number of API keys attached.
To access a given endpoint the team the API key belongs to needs to have the corresponding
permission for the organizer/event being accessed.
Possible permissions are:
* Can create events
* Can change event settings
* Can change product settings
* Can view orders
* Can change orders
* Can view vouchers
* Can change vouchers
Compatibility
-------------
@@ -90,6 +113,41 @@ respective page.
The field ``results`` contains a list of objects representing the first results. For most
objects, every page contains 50 results.
Conditional fetching
--------------------
If you pull object lists from pretix' APIs regularly, we ask you to implement conditional fetching
to avoid unnecessary data traffic. This is not supported on all resources and we currently implement
two different mechanisms for different resources, which is necessary because we can only obtain best
efficiency for resources that do not support deletion operations.
Object-level conditional fetching
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The :ref:`rest-orders` resource list contains an HTTP header called ``X-Page-Generated`` containing the
current time on the server in ISO 8601 format. On your next request, you can pass this header
(as is, without any modifications necessary) as the ``modified_since`` query parameter and you will receive
a list containing only objects that have changed in the time since your last request.
List-level conditional fetching
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
If modification checks are not possible with this granularity, you can instead check for the full list.
In this case, the list of objects may contain a regular HTTP header ``Last-Modified`` with the date of the
last modification to any item of that resource. You can then pass this date back in your next request in the
``If-Modified-Since`` header. If the any object has changed in the meantime, you will receive back a full list
(if something it missing, this means the object has been deleted). If nothing happened, we'll send back a
``304 Not Modified`` return code.
This is currently implemented on the following resources:
* :ref:`rest-categories`
* :ref:`rest-items`
* :ref:`rest-questions`
* :ref:`rest-quotas`
* :ref:`rest-subevents`
* :ref:`rest-taxrules`
Errors
------

View File

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

171
doc/api/oauth.rst Normal file
View File

@@ -0,0 +1,171 @@
.. _`rest-oauth`:
OAuth support / "Connect with pretix"
=====================================
In addition to static tokens, pretix supports `OAuth2`_-based authentication starting with
pretix 1.16. This allows you to put a "Connect with pretix" button into your website or tool
that allows the user to easily set up a connection between the two systems.
If you haven't worked with OAuth before, have a look at the `OAuth2 Simplified`_ tutorial.
Registering an application
--------------------------
To use OAuth, you need to register your application with the pretix instance you want to connect to.
In order to do this, log in to your pretix account and go to your user settings. Click on "Authorized applications"
first and then on "Manage your own apps". From there, you can "Create a new application".
You should fill in a descriptive name of your application that allows users to recognize who you are. You also need to
give a list of fully-qualified URLs that users will be redirected to after a successful authorization. After you pressed
"Save", you will be presented with a client ID and a client secret. Please note them down and treat the client secret
like a password; it should not become available to your users.
Obtaining an authorization grant
--------------------------------
To authorize a new user, link or redirect them to the ``authorize`` endpoint, passing your client ID as a query
parameter. Additionally, you can pass a scope (currently either ``read``, ``write``, or ``read write``)
and an URL the user should be redirected to after successful or failed authorization. You also need to pass the
``response_type`` parameter with a value of ``code``. Example::
https://pretix.eu/api/v1/oauth/authorize?client_id=lsLi0hNL0vk53mEdYjNJxHUn1PcO1R6wVg81dLNT&response_type=code&scope=read+write&redirect_uri=https://pretalx.com
To prevent CSRF attacks, you can also optionally pass a ``state`` parameter with a random string. Later, when
redirecting back to your application, we will pass the same ``state`` parameter back to you, so you can compare if they
match.
After the user granted or denied access, they will be redirected back either to the ``redirect_url`` you passed in the
query or to the first redirect URL configured in your application settings.
On successful registration, we will append the query parameter ``code`` to the URL containing an authorization code.
For example, we might redirect the user to this URL::
https://pretalx.com/?code=eYBBf8gmeD4E01HLoj0XflqO4Lg3Cw&state=e3KCh9mfx07qxU4bRpXk
You will need this ``code`` parameter to perform the next step.
On a failed registration, a query string like ``?error=access_denied`` will be appended to the redirection URL.
.. note:: In this step, the user is allowed to restrict your access to certain organizer accounts. If you try to
re-authenticate the user later, the user might be instantly redirected back to you if authorization is already
given and would therefore be unable to review their organizer restriction settings. You can append the
``approval_prompt=force`` query parameter if you want to make sure the user actively needs to confirm the
authorization.
Getting an access token
-----------------------
Using the ``code`` value you obtained above and your client ID, you can now request an access token that actually gives
access to the API. The ``token`` endpoint expects you to authenticate using `HTTP Basic authentication`_ using your client
ID as a username and your client secret as a password. You are also required to again supply the same ``redirect_uri``
parameter that you used for the authorization.
.. http:get:: /api/v1/oauth/token
Request a new access token
**Example request**:
.. sourcecode:: http
POST /api/v1/oauth/token HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg==
grant_type=authorization_code&code=eYBBf8gmeD4E01HLoj0XflqO4Lg3Cw&redirect_uri=https://pretalx.com
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"access_token": "i3ytqTSRWsKp16fqjekHXa4tdM4qNC",
"expires_in": 86400,
"token_type": "Bearer",
"scope": "read write",
"refresh_token": "XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp"
}
:statuscode 200: no error
:statuscode 401: Authentication failure
As you can see, you receive two types of tokens: One "access token", and one "refresh token". The access token is valid
for a day and can be used to actually access the API. The refresh token does not have an expiration date and can be used
to obtain a new access_token after a day, so you should make sure to store the access token safely if you need long-term
access.
Using the API with an access token
----------------------------------
You can supply a valid access token as a ``Bearer``-type token in the ``Authorization`` header to get API access.
.. sourcecode:: http
:emphasize-lines: 3
GET /api/v1/organizers/ HTTP/1.1
Host: pretix.eu
Authorization: Bearer i3ytqTSRWsKp16fqjekHXa4tdM4qNC
Refreshing an access token
--------------------------
You can obtain a new access token using your refresh token any time. This can be done using the same ``token`` endpoint
used to obtain the first access token above, but with a different set of parameters:
.. sourcecode:: http
POST /api/v1/oauth/token HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg==
grant_type=refresh_token&refresh_token=XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp
The previous access token will instantly become invalid.
Revoking a token
----------------
If you don't need a token any more or if you believe it may have been compromised, you can use the ``revoke_token``
endpoint to revoke it.
.. http:get:: /api/v1/oauth/revoke_token
Revoke an access or refresh token. If you revoke an access token, you can still create a new one using the refresh token. If you
revoke a refresh token, the connected access token will also be revoked.
**Example request**:
.. sourcecode:: http
POST /api/v1/oauth/revoke_token HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Authorization: Basic bHNMaTBoTkwwdms1M21FZFlqTkp4SFVuMVBjTzFSNndWZzgxZExOVDplSmpzZVA0UjJMN0hMcjBiS0p1b3BmbnJtT2cyY3NDeTdYaFVVZ0FoalhUU0NhZHhRTjk3cVNvMkpPaXlWTFpQOEozaTVQd1FVdFIwNUNycG5ac2Z0bXJjdmNTbkZ1SkFmb2ZsUTdZUDRpSjZNTWFYTHIwQ0FpNlhIRFJjV1Awcg==
token=XBK0r8z4A4TTeR9LyMUyU2AM5rqpXp
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
:statuscode 200: no error
:statuscode 401: Authentication failure
If you want to revoke your client secret, you can generate a new one in the list of your managed applications in the
pretix user interface.
.. _OAuth2: https://en.wikipedia.org/wiki/OAuth
.. _OAuth2 Simplified: https://aaronparecki.com/oauth-2-simplified/
.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication

View File

@@ -1,3 +1,5 @@
.. _`rest-categories`:
Item categories
===============
@@ -14,6 +16,7 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the category
name multi-lingual string The category's visible name
internal_name string An optional name that is only used in the backend
description multi-lingual string A public description (might include markdown, can
be ``null``)
position integer An integer, used for sorting the categories
@@ -26,6 +29,10 @@ is_addon boolean If ``True``, it
The operations POST, PATCH, PUT and DELETE have been added.
.. versionchanged:: 1.16
The field ``internal_name`` has been added.
Endpoints
---------
@@ -58,6 +65,7 @@ Endpoints
{
"id": 1,
"name": {"en": "Tickets"},
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": false
@@ -99,6 +107,7 @@ Endpoints
{
"id": 1,
"name": {"en": "Tickets"},
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": false
@@ -126,6 +135,7 @@ Endpoints
{
"name": {"en": "Tickets"},
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": false
@@ -142,6 +152,7 @@ Endpoints
{
"id": 1,
"name": {"en": "Tickets"},
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": false
@@ -187,6 +198,7 @@ Endpoints
{
"id": 1,
"name": {"en": "Tickets"},
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": true

View File

@@ -44,6 +44,10 @@ include_pending boolean If ``true``, th
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.
@@ -128,6 +132,72 @@ Endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(id)/status/
Returns detailed status information on a check-in list, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/status/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"checkin_count": 17,
"position_count": 42,
"event": {
"name": "Demo Converence",
},
"items": [
{
"name": "T-Shirt",
"id": 1,
"checkin_count": 1,
"admission": False,
"position_count": 1,
"variations": [
{
"value": "Red",
"id": 1,
"checkin_count": 1,
"position_count": 12
},
{
"value": "Blue",
"id": 2,
"checkin_count": 4,
"position_count": 8
}
]
},
{
"name": "Ticket",
"id": 2,
"checkin_count": 15,
"admission": True,
"position_count": 22,
"variations": []
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the check-in list to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/
Creates a new check-in list.
@@ -254,6 +324,14 @@ 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.
.. 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
@@ -297,6 +375,7 @@ Order position endpoints
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"checkins": [
{
"list": 1,
@@ -325,15 +404,24 @@ Order position endpoints
``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default:
``attendee_name,positionid``
:query string order: Only return positions of the order with the given order code
:query string search: Fuzzy search matching the attendee name, order code, invoice address name as well as to the beginning of the secret.
:query integer item: Only return positions with the purchased item matching the given ID.
:query integer item__in: Only return positions with the purchased item matching one of the given comma-separated IDs.
:query integer variation: Only return positions with the purchased item variation matching the given ID.
:query integer variation__in: Only return positions with one of the purchased item variation matching the given
comma-separated IDs.
:query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on
products positions are shown if they refer to an attendee with the given name.
:query string secret: Only return positions with the given ticket secret.
:query bollean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
checked in already on this list.
:query string order__status: Only return positions with the given order status.
:query string order__status__in: Only return positions with one the given comma-separated order status.
:query boolean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
checked in already.
:query integer subevent: Only return positions of the sub-event with the given ID
:query integer subevent__in: Only return positions of one of the sub-events with the given comma-separated IDs
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
comma-separated IDs.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param list: The ID of the check-in list to look for
@@ -342,7 +430,7 @@ Order position endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested check-in list does not exist.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/
Returns information on one order position, identified by its internal ID.
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
@@ -352,7 +440,7 @@ Order position endpoints
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/ HTTP/1.1
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/23442/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
@@ -380,6 +468,7 @@ Order position endpoints
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"checkins": [
{
"list": 1,
@@ -409,3 +498,127 @@ Order position endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position or check-in list does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/redeem/
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
accepts a number of optional requests in the body.
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
you do not implement question handling in your user interface, you **must**
set this to ``false``. In that case, questions will just be ignored. Defaults
to ``true``.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
questions that have not been filled. Defaults to ``false``.
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
Defaults to ``false``.
:<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
this request twice with the same nonce, the second request will also succeed but will always
create only one check-in object even when the previous request was successful as well. This
allows for a certain level of idempotency and enables you to re-try after a connection failure.
:<json object answers: If questions are supported/required, you may/must supply a mapping of question IDs to their
respective answers. The answers should always be strings. In case of (multiple-)choice-type
answers, the string should contain the (comma-separated) IDs of the selected options.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/234/redeem/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
{
"force": false,
"ignore_unpaid": false,
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
"datetime": null,
"questions_supported": true,
"answers": {
"4": "XS"
}
}
**Example successful response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"status": "ok"
}
**Example response with required questions**:
.. sourcecode:: http
HTTP/1.1 400 Bad Request
Content-Type: text/json
{
"status": "incomplete"
"questions": [
{
"id": 1,
"question": {"en": "T-Shirt size"},
"type": "C",
"required": false,
"items": [1, 2],
"position": 1,
"identifier": "WY3TP9SL",
"ask_during_checkin": true,
"options": [
{
"id": 1,
"identifier": "LVETRWVU",
"position": 0,
"answer": {"en": "S"}
},
{
"id": 2,
"identifier": "DFEMJWMJ",
"position": 1,
"answer": {"en": "M"}
},
{
"id": 3,
"identifier": "W9AH7RDE",
"position": 2,
"answer": {"en": "L"}
}
]
}
]
}
**Example error response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "error",
"reason": "unpaid",
}
Possible error reasons:
* ``unpaid`` - Ticket is not paid for or has been refunded
* ``already_redeemed`` - Ticket already has been redeemed
* ``product`` - Tickets with this product may not be scanned at this device
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param list: The ID of the check-in list to look for
:param id: The ``id`` field of the order position to fetch
:statuscode 201: no error
:statuscode 400: Invalid or incomplete request, see above
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position or check-in list does not exist.

View File

@@ -25,14 +25,22 @@ presale_start datetime The date at whi
presale_end datetime The date at which the ticket shop closes (or ``null``)
location multi-lingual string The event location (or ``null``)
has_subevents boolean ``True`` if the event series feature is active for this
event
event. Cannot change after event is created.
meta_data dict Values set for organizer-specific meta data parameters.
plugins list A list of package names of the enabled plugins for this
event.
===================================== ========================== =======================================================
.. 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.
Endpoints
---------
@@ -40,6 +48,8 @@ Endpoints
Returns a list of all events within a given organizer the authenticated user/token has access to.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
@@ -74,7 +84,13 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {}
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer"
"pretix.plugins.stripe"
"pretix.plugins.paypal"
"pretix.plugins.ticketoutputpdf"
]
}
]
}
@@ -89,6 +105,8 @@ Endpoints
Returns information on one event, identified by its slug.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
@@ -118,7 +136,13 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {}
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer"
"pretix.plugins.stripe"
"pretix.plugins.paypal"
"pretix.plugins.ticketoutputpdf"
]
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -126,3 +150,242 @@ Endpoints
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/
Creates a new event
Please note that events cannot be created as 'live' using this endpoint. Quotas and payment must be added to the
event before sales can go live.
Permission required: "Can create events"
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
]
}
:param organizer: The ``slug`` field of the organizer of the event to create.
:statuscode 201: no error
:statuscode 400: The event could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/
Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public',
settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise
their value will be copied from the existing event.
Please note that you can only copy from events under the same organizer.
Permission required: "Can create events"
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
]
}
:param organizer: The ``slug`` field of the organizer of the event to create.
:param event: The ``slug`` field of the event to copy settings and items from.
:statuscode 201: no error
:statuscode 400: The event could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/
Updates an event
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.pretixdroid"
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.pretixdroid"
]
}
:param organizer: The ``slug`` field of the organizer of the event to update
:param event: The ``slug`` field of the event to update
:statuscode 201: no error
:statuscode 400: The event could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/
Delete an event. Note that events with orders cannot be deleted to ensure data integrity.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.

View File

@@ -223,3 +223,59 @@ Endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
Cancels the invoice and creates a new one.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/reissue/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/pdf
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param invoice_no: The ``invoice_no`` field of the invoice to reissue
:statuscode 200: no error
:statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/regenerate/
Re-generates the invoice from order data.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/regenerate/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/pdf
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param invoice_no: The ``invoice_no`` field of the invoice to regenerate
:statuscode 200: no error
:statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.

View File

@@ -1,3 +1,5 @@
.. _rest-items:
Items
=====
@@ -14,6 +16,7 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the item
name multi-lingual string The item's visible name
internal_name string An optional name that is only used in the backend
default_price money (string) The item price that is applied if the price is not
overwritten by variations or other options.
category integer The ID of the category this item belongs to
@@ -54,11 +57,14 @@ max_per_order integer This product ca
checkin_attention boolean If ``True``, the check-in app should show a warning
that this ticket requires special attention if such
a product is being scanned.
original_price money (string) An original price, shown for comparison, not used
for price calculations.
has_variations boolean Shows whether or not this item has variations.
variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
├ id integer Internal ID of the variation
├ value multi-lingual string The "name" of the variation
├ default_price money (string) The price set directly for this variation or ``null``
├ price money (string) The price used for this variation. This is either the
same as ``default_price`` if that value is set or equal
@@ -88,6 +94,10 @@ addons list of objects Definition of a
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
The attribute ``price_included`` has been added to ``addons``.
.. versionchanged:: 1.16
The field ``internal_name`` and ``original_price`` fields have been added.
Notes
-----
Please note that an item either always has variations or never has. Once created with variations the item can never
@@ -129,7 +139,9 @@ Endpoints
{
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"default_price": "23.00",
"original_price": null,
"category": null,
"active": true,
"description": null,
@@ -211,7 +223,9 @@ Endpoints
{
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"default_price": "23.00",
"original_price": null,
"category": null,
"active": true,
"description": null,
@@ -274,7 +288,9 @@ Endpoints
{
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"default_price": "23.00",
"original_price": null,
"category": null,
"active": true,
"description": null,
@@ -324,7 +340,9 @@ Endpoints
{
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"default_price": "23.00",
"original_price": null,
"category": null,
"active": true,
"description": null,
@@ -406,7 +424,9 @@ Endpoints
{
"id": 1,
"name": {"en": "Ticket"},
"internal_name": "",
"default_price": "25.00",
"original_price": null,
"category": null,
"active": true,
"description": null,

View File

@@ -1,4 +1,10 @@
.. spelling:: checkins
.. spelling::
checkins
pdf
.. _rest-orders:
Orders
======
@@ -28,10 +34,6 @@ datetime datetime Time of order c
expires datetime The order will expire, if it is still pending by this time
payment_date date Date of payment receipt
payment_provider string Payment provider used for this order
payment_fee money (string) Payment fee included in this order's total
payment_fee_tax_rate decimal (string) Tax rate applied to the payment fee
payment_fee_tax_value money (string) Tax value included in the payment fee
payment_fee_tax_rule integer The ID of the used tax rule (or ``null``)
total money (string) Total value of this order
comment string Internal comment on this order
checkin_attention boolean If ``True``, the check-in app should show a warning
@@ -53,7 +55,7 @@ invoice_address object Invoice address
└ vat_id_validated string ``True``, if the VAT ID has been validated against the
EU VAT service and validation was successful. This only
happens in rare cases.
position list of objects List of order positions (see below)
positions list of objects List of order positions (see below)
fees list of objects List of fees included in the order total (i.e.
payment fees)
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
@@ -72,6 +74,7 @@ downloads list of objects List of ticket
download options.
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
└ url string Download URL
last_modified datetime Last modification of this object
===================================== ========================== =======================================================
@@ -95,6 +98,16 @@ downloads list of objects List of ticket
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.
.. _order-position-resource:
Order position resource
@@ -106,7 +119,7 @@ Order position resource
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the order position
code string Order code of the order the position belongs to
order string Order code of the order the position belongs to
positionid integer Number of the position within the order
item integer ID of the purchased item
variation integer ID of the purchased variation (or ``null``)
@@ -120,6 +133,7 @@ tax_rule integer The ID of the u
secret string Secret code printed on the tickets for validation
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
checkins list of objects List of check-ins with this ticket
├ list integer Internal ID of the check-in list
└ datetime datetime Time of check-in
@@ -132,6 +146,9 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
pdf_data object Data object required for ticket PDF generation. By default,
this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request.
===================================== ========================== =======================================================
.. versionchanged:: 1.7
@@ -146,10 +163,18 @@ answers list of objects Answers to user
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.
Order endpoints
---------------
.. versionchanged:: 1.15
Filtering for emails or order codes is now case-insensitive.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/
Returns a list of all orders within a given event.
@@ -169,6 +194,7 @@ Order endpoints
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
@@ -183,6 +209,7 @@ Order endpoints
"locale": "en",
"datetime": "2017-12-01T10:00:00Z",
"expires": "2017-12-10T10:00:00Z",
"last_modified": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-05",
"payment_provider": "banktransfer",
"fees": [],
@@ -219,6 +246,7 @@ Order endpoints
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"checkins": [
{
"list": 44,
@@ -259,8 +287,11 @@ Order endpoints
:query string status: Only return orders in the given order status (see above)
:query string email: Only return orders created with the given email address
:query string locale: Only return orders with the given customer locale
:query datetime modified_since: Only return orders that have changed since the given date
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
differences, this is the value you want to use as ``modified_since`` in your next call.
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
@@ -293,6 +324,7 @@ Order endpoints
"locale": "en",
"datetime": "2017-12-01T10:00:00Z",
"expires": "2017-12-10T10:00:00Z",
"last_modified": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-05",
"payment_provider": "banktransfer",
"fees": [],
@@ -329,6 +361,7 @@ Order endpoints
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"checkins": [
{
"list": 44,
@@ -409,6 +442,179 @@ Order endpoints
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/
Creates a new order.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. warning::
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend,
it's rather intended to import attendees from external sources etc.
There is a lot that it does not or can not do, and you will need to be careful using it.
It allows to bypass many of the restrictions imposed when creating an order through the
regular shop.
Specifically, this endpoint currently
* does not validate if products are only to be sold in a specific time frame
* does not validate if the event's ticket sales are already over or haven't started
* does not validate the number of items per order or the number of times an item can be included in an order
* does not validate any requirements related to add-on products
* does not check or calculate prices but believes any prices you send
* does not support the redemption of vouchers
* does not prevent you from buying items that can only be bought with a voucher
* does not calculate fees
* does not allow to pass data to plugins and will therefore cause issues with some plugins like the shipping
module
* does not send order confirmations via email
* does not support reverse charge taxation
* does not support file upload questions
You can supply the following fields of the resource:
* ``code`` (optional)
* ``status`` (optional) Defaults to pending for non-free orders and paid for free orders. You can only set this to
``"n"`` for pending or ``"p"`` for paid. If you create a paid order, the ``order_paid`` signal will **not** be
sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call
the ``mark_paid`` API method.
* ``email``
* ``locale``
* ``payment_provider`` The identifier of the payment provider set for this order. This needs to be an existing
payment provider. You should use ``"free"`` for free orders.
* ``payment_info`` (optional) You can pass a nested JSON object that will be set as the internal ``payment_info``
value of the order. How this value is handled is up to the payment provider and you should only use this if you
know the specific payment provider in detail. Please keep in mind that the payment provider will not be called
to do anything about this (i.e. if you pass a bank account to a debit provider, *no* charge will be created),
this is just informative in case you *handled the payment already*.
* ``comment`` (optional)
* ``checkin_attention`` (optional)
* ``invoice_address`` (optional)
* ``company``
* ``is_business``
* ``name``
* ``street``
* ``zipcode``
* ``city``
* ``country``
* ``internal_reference``
* ``vat_id``
* ``positions``
* ``positionid`` (optional, see below)
* ``item``
* ``variation``
* ``price``
* ``attendee_name``
* ``attendee_email``
* ``secret`` (optional)
* ``addon_to`` (optional, see below)
* ``subevent``
* ``answers``
* ``question``
* ``answer``
* ``options``
* ``fees``
* ``fee_type``
* ``value``
* ``description``
* ``internal_type``
* ``tax_rule``
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
to incrementing integers starting with ``1``. Then, you can reference one of these
IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come
immediately after the position itself.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"email": "dummy@example.org",
"locale": "en",
"fees": [
{
"fee_type": "payment",
"value": "0.25",
"description": "",
"internal_type": "",
"tax_rule": 2
}
],
"payment_provider": "banktransfer",
"invoice_address": {
"is_business": False,
"company": "Sample company",
"name": "John Doe",
"street": "Sesam Street 12",
"zipcode": "12345",
"city": "Sample City",
"country": "UK",
"internal_reference": "",
"vat_id": ""
},
"positions": [
{
"positionid": 1,
"item": 1,
"variation": None,
"price": "23.00",
"attendee_name": "Peter",
"attendee_email": None,
"addon_to": None,
"answers": [
{
"question": 1,
"answer": "23",
"options": []
}
],
"subevent": None
}
],
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
(Full order resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event to create an item for
:param event: The ``slug`` field of the event to create an item for
:statuscode 201: no error
:statuscode 400: The item could not be created due to invalid submitted data or lack of quota.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
order.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
Marks a pending or expired order as successfully paid.
@@ -520,6 +726,44 @@ Order endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_refunded/
Marks a paid order as refunded.
.. warning:: In the current implementation, this will **bypass** the payment provider, i.e. the money will **not** be
transferred back to the user automatically, the order will only be *marked* as refunded within pretix.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_expired/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"code": "ABC12",
"status": "r",
...
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param code: The ``code`` field of the order to modify
:statuscode 200: no error
:statuscode 400: The order cannot be marked as expired since the current order status does not allow it.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
Marks a unpaid order as expired.
@@ -609,6 +853,12 @@ Order 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.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Returns a list of all order positions within a given event.
@@ -648,6 +898,7 @@ Order position endpoints
"tax_rule": null,
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"pseudonymization_id": "MQLJvANO3B",
"addon_to": null,
"subevent": null,
"checkins": [
@@ -680,16 +931,24 @@ Order position endpoints
``order__datetime``, ``positionid``, ``attendee_name``, and ``order__status``. Default:
``order__datetime,positionid``
:query string order: Only return positions of the order with the given order code
:query string search: Fuzzy search matching the attendee name, order code, invoice address name as well as to the beginning of the secret.
:query integer item: Only return positions with the purchased item matching the given ID.
:query integer item__in: Only return positions with the purchased item matching one of the given comma-separated IDs.
:query integer variation: Only return positions with the purchased item variation matching the given ID.
:query integer variation__in: Only return positions with one of the purchased item variation matching the given
comma-separated IDs.
:query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on
products positions are shown if they refer to an attendee with the given name.
:query string secret: Only return positions with the given ticket secret.
:query string order__status: Only return positions with the given order status.
:query bollean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
:query string order__status__in: Only return positions with one the given comma-separated order status.
:query boolean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
checked in already.
:query integer subevent: Only return positions of the sub-event with the given ID
:query integer subevent__in: Only return positions of one of the sub-events with the given comma-separated IDs
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
comma-separated IDs.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
@@ -732,6 +991,7 @@ Order position endpoints
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"checkins": [
{
"list": 44,

View File

@@ -1,5 +1,7 @@
.. spelling:: checkin
.. _rest-questions:
Questions
=========
@@ -59,6 +61,11 @@ options list of objects In case of ques
Endpoints
---------
.. versionchanged:: 1.15
The questions endpoint has been extended by the filter queries ``ask_during_checkin``, ``requred``, and
``identifier``.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/
Returns a list of all questions within a given event.
@@ -120,6 +127,9 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
Default: ``position``
:query string identifier: Only return questions with the given identifier string
:query boolean ask_during_checkin: Only return questions that are or are not to be asked during check-in
:query boolean required: Only return questions that are or are not required to fill in
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error

View File

@@ -1,3 +1,5 @@
.. _rest-quotas:
Quotas
======

View File

@@ -1,3 +1,5 @@
.. _rest-subevents:
Event series dates / Sub-events
===============================

View File

@@ -1,3 +1,5 @@
.. _rest-taxrules:
Tax rules
=========

View File

@@ -251,7 +251,7 @@ Endpoints
{
"price_mode": "set",
"value": "24.00",
"value": "24.00"
}
**Example response**:

View File

@@ -27,6 +27,12 @@ subevent integer ID of the date
===================================== ========================== =======================================================
.. versionchanged:: 1.15
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added as well as a method to send out
vouchers.
Endpoints
---------
@@ -121,3 +127,161 @@ Endpoints
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/
Create a new entry.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 408
{
"email": "waiting@example.org",
"item": 3,
"variation": null,
"locale": "de",
"subevent": null
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"created": "2017-12-01T10:00:00Z",
"email": "waiting@example.org",
"voucher": null,
"item": 3,
"variation": null,
"locale": "de",
"subevent": null
}
:param organizer: The ``slug`` field of the organizer to create an entry for
:param event: The ``slug`` field of the event to create an entry for
:statuscode 201: no error
:statuscode 400: The voucher could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
resource **or** entries cannot be created for this item at this time.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/(id)/
Update an entry. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id``, ``voucher`` and ``created`` fields. You can only change
an entry as long as no ``voucher`` is set.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 408
{
"item": 4
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"created": "2017-12-01T10:00:00Z",
"email": "waiting@example.org",
"voucher": null,
"item": 4,
"variation": null,
"locale": "de",
"subevent": null
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the entry to modify
:statuscode 200: no error
:statuscode 400: The entry could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
resource **or** entries cannot be created for this item at this time **or** this entry already
has a voucher assigned
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/(id)/send_voucher/
Manually sends a voucher to someone on the waiting list
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/1/send_voucher/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 0
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the entry to modify
:statuscode 204: no error
:statuscode 400: The voucher could not be sent out, see body for details (e.g. voucher has already been sent or
item is not available).
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to do this
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/(id)/
Delete an entry. Note that you cannot delete an entry once it is assigned a voucher.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the entry to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this
resource **or** this entry already has a voucher assigned.

View File

@@ -11,7 +11,8 @@ Core
----
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data
Order events
""""""""""""
@@ -47,7 +48,7 @@ Backend
-------
.. automodule:: pretix.control.signals
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, order_info, event_settings_widget
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, order_info, event_settings_widget, oauth_application_registered
.. automodule:: pretix.base.signals
@@ -56,6 +57,12 @@ Backend
Vouchers
""""""""
.. automodule:: pretix.control.signals
:members: item_forms
Vouchers
""""""""
.. automodule:: pretix.control.signals
:members: voucher_form_class, voucher_form_html, voucher_form_validation
@@ -68,5 +75,5 @@ Dashboards
Ticket designs
""""""""""""""
.. automodule:: pretix.plugins.ticketoutputpdf.signals
.. automodule:: pretix.base.signals
:members: layout_text_variables

View File

@@ -11,5 +11,7 @@ Contents:
ticketoutput
payment
invoice
shredder
customview
general
quality

View File

@@ -104,6 +104,8 @@ The provider class
.. automethod:: is_implicit
.. automethod:: shred_payment_info
Additional views
----------------

View File

@@ -0,0 +1,125 @@
.. highlight:: python
:linenothreshold: 5
.. _`pluginquality`:
Plugin quality checklist
========================
If you want to write a high-quality pretix plugin, this is a list of things you should check before
you publish it. This is also a list of things that we check, if we consider installing an externally
developed plugin on our hosted infrastructure.
A. Meta
-------
#. The plugin is clearly licensed under an appropriate license.
#. The plugin has an unambiguous name, description, and author metadata.
#. The plugin has a clear versioning scheme and the latest version of the plugin is kept compatible to the latest
stable version of pretix.
#. The plugin is properly packaged using standard Python packaging tools.
#. The plugin correctly declares its external dependencies.
#. A contact address is provided in case of security issues.
B. Isolation
------------
#. If any signal receivers use the `dispatch_uid`_ feature, the UIDs are prefixed by the plugin's name and do not
clash with other plugins.
#. If any templates or static files are shipped, they are located in subdirectories with the name of the plugin and do
not clash with other plugins or core files.
#. Any keys stored to the settings store are prefixed with the plugin's name and do not clash with other plugins or
core.
#. Any keys stored to the user session are prefixed with the plugin's name and do not clash with other plugins or
core.
#. Any registered URLs are unlikely to clash with other plugins or future core URLs.
C. Security
-----------
#. All important actions are logged to the :ref:`shared log storage <logging>` and a signal receiver is registered to
provide a human-readable representation of the log entry.
#. All views require appropriate permissions and use the ``event_urls`` mechanism if appropriate.
:ref:`Read more <customview>`
#. Any session data for customers is stored in the cart session system if appropriate.
#. If the plugin is a payment provider:
#. No credit card numbers may be stored within pretix.
#. A notification/webhook system is implemented to notify pretix of any refunds.
#. If such a webhook system is implemented, contents of incoming webhooks are either verified using a cryptographic
signature or are not being trusted and all data is fetched from an API instead.
D. Privacy
----------
#. No personal data is stored that is not required for the plugin's functionality.
#. For any personal data that is saved to the database, an appropriate :ref:`data shredder <shredder>` is provided
that offers the data for download and then removes it from the database (including log entries).
E. Internationalization
-----------------------
#. All user-facing strings in templates, Python code, and templates are wrapped in `gettext calls`_.
#. No languages, time zones, date formats, or time formats are hardcoded.
#. Installing the plugin automatically compiles ``.po`` files to ``.mo`` files. This is fulfilled automatically if
you use the ``setup.py`` file form our plugin cookiecutter.
F. Functionality
----------------
#. If the plugin adds any database models or relationships from the settings storage to database models, it registers
a receiver to the :py:attr:`pretix.base.signals.event_copy_data` or :py:attr:`pretix.base.signals.item_copy_data`
signals.
#. If the plugin is a payment provider:
#. A webhook-like system is implemented if payment confirmations are not sent instantly.
#. Refunds are implemented, if possible.
#. In case of overpayment or external refunds, a "required action" is created to notify the event organizer.
#. If the plugin adds steps to the checkout process, it has been tested in combination with the pretix widget.
G. Code quality
---------------
#. `isort`_ and `flake8`_ are used to ensure consistent code styling.
#. Unit tests are provided for important pieces of business logic.
#. Functional tests are provided for important interface parts.
#. Tests are provided to check that permission checks are working.
#. Continuous Integration is set up to check that tests are passing and styling is consistent.
H. Specific to pretix.eu
------------------------
#. pretix.eu integrates the data stored by this plugin with its data report features.
#. pretix.eu integrates this plugin in its generated privacy statements, if necessary.
.. _isort: https://www.google.de/search?q=isort&oq=isort&aqs=chrome..69i57j0j69i59j69i60l2j69i59.599j0j4&sourceid=chrome&ie=UTF-8
.. _flake8: http://flake8.pycqa.org/en/latest/
.. _gettext calls: https://docs.djangoproject.com/en/2.0/topics/i18n/translation/
.. _dispatch_uid: https://docs.djangoproject.com/en/2.0/topics/signals/#django.dispatch.Signal.connect

View File

@@ -0,0 +1,94 @@
.. highlight:: python
:linenothreshold: 5
.. _`shredder`:
Writing a data shredder
=======================
If your plugin adds the ability to store personal data within pretix, you should also implement a "data shredder"
to anonymize or pseudonymize the data later.
Shredder registration
---------------------
The data shredder API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available data shredders. Your plugin
should listen for this signal and return the subclass of ``pretix.base.shredder.BaseDataShredder``
that we'll provide in this plugin:
.. sourcecode:: python
from django.dispatch import receiver
from pretix.base.signals import register_data_shredders
@receiver(register_data_shredders, dispatch_uid="custom_data_shredders")
def register_shredder(sender, **kwargs):
return [
PluginDataShredder,
]
The shredder class
------------------
.. class:: pretix.base.shredder.BaseDataShredder
The central object of each invoice renderer is the subclass of ``BaseInvoiceRenderer``.
.. py:attribute:: BaseInvoiceRenderer.event
The default constructor sets this property to the event we are currently
working for.
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: verbose_name
This is an abstract attribute, you **must** override this!
.. autoattribute:: description
This is an abstract attribute, you **must** override this!
.. automethod:: generate_files
.. automethod:: shred_data
Example
-------
For example, the core data shredder responsible for removing invoice address information including their history
looks like this:
.. sourcecode:: python
class InvoiceAddressShredder(BaseDataShredder):
verbose_name = _('Invoice addresses')
identifier = 'invoice_addresses'
description = _('This will remove all invoice addresses from orders, '
'as well as logged changes to them.')
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'invoice-addresses.json', 'application/json', json.dumps({
ia.order.code: InvoiceAdddressSerializer(ia).data
for ia in InvoiceAddress.objects.filter(order__event=self.event)
}, indent=4)
@transaction.atomic
def shred_data(self):
InvoiceAddress.objects.filter(order__event=self.event).delete()
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified"):
d = le.parsed_data
if 'invoice_data' in d and not isinstance(d['invoice_data'], bool):
for field in d['invoice_data']:
if d['invoice_data'][field]:
d['invoice_data'][field] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])

View File

@@ -4,6 +4,8 @@ Logging and notifications
As pretix is handling monetary transactions, we are very careful to make it possible to review all changes
in the system that lead to the current state.
.. _`logging`:
Logging changes
---------------

View File

@@ -115,12 +115,19 @@ Execute the following command to run pretix' test suite (might take a couple of
``NUM`` being the number of threads you want to use.
It is a good idea to put this command into your git hook ``.git/hooks/pre-commit``,
for example::
for example, to check for any errors in any staged files when committing::
#!/bin/sh
#!/bin/bash
cd $GIT_DIR/../src
flake8 . || exit 1
isort -q -rc -c . || exit 1
export GIT_WORK_TREE=../
export GIT_DIR=../.git
source ../env/bin/activate # Adjust to however you activate your virtual environment
for file in $(git diff --cached --name-only | grep -E '\.py$')
do
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
git show ":$file" | isort -df --check-only - | grep ERROR && exit 1 || true
done
This keeps you from accidentally creating commits violating the style guide.

110
doc/plugins/badges.rst Normal file
View File

@@ -0,0 +1,110 @@
Badges
======
The badges plugin provides a HTTP API that exposes the various layouts used to generate PDF badges.
Resource description
--------------------
The badge layout resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal layout ID
name string Internal layout description
default boolean ``true`` if this is the default layout
layout object Layout specification for libpretixprint
background URL Background PDF file
item_assignments list of objects Products this layout is assigned to
└ item integer Item ID
===================================== ========================== =======================================================
.. versionchanged:: 1.16
This resource has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/badgelayouts/
Returns a list of all badge layouts
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/democon/badgelayouts/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "Default layout",
"default": true,
"layout": {…},
"background": {},
"item_assignments": []
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/badgelayouts/(id)/
Returns information on layout.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/democon/layoutsbadge/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 1,
"name": "Default layout",
"default": true,
"layout": {…},
"background": {},
"item_assignments": []
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the layout to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.

View File

@@ -12,3 +12,5 @@ If you want to **create** a plugin, please go to the
list
pretixdroid
banktransfer
ticketoutputpdf
badges

View File

@@ -4,10 +4,10 @@ pretixdroid HTTP API
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
uses to communicate with the pretix server.
.. warning:: This API is intended **only** to serve the pretixdroid Android app. There are no backwards compatibility
guarantees on this API. We will not add features that are not required for the Android App. There is a
general-purpose :ref:`rest-api` that not yet provides all features that this API provides, but will do
so in the future.
.. warning:: This API is **DEPRECATED** and will probably go away soon. It is used **only** to serve the pretixdroid
Android app. There are no backwards compatibility guarantees on this API. We will not add features that
are not required for the Android App. There is a general-purpose :ref:`rest-api` that provides all
features that you need to check in.
.. versionchanged:: 1.12
@@ -81,6 +81,7 @@ uses to communicate with the pretix server.
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
}
}
@@ -106,6 +107,7 @@ uses to communicate with the pretix server.
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
},
"questions": [
@@ -152,6 +154,7 @@ uses to communicate with the pretix server.
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
}
}
@@ -212,6 +215,7 @@ uses to communicate with the pretix server.
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
},
...

View File

@@ -0,0 +1,111 @@
PDF ticket output
=================
The PDF ticket output plugin provides a HTTP API that exposes the various layouts used
to generate PDF tickets.
Resource description
--------------------
The ticket layout resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal layout ID
name string Internal layout description
default boolean ``true`` if this is the default layout
layout object Layout specification for libpretixprint
background URL Background PDF file
item_assignments list of objects Products this layout is assigned to
└ item integer Item ID
===================================== ========================== =======================================================
.. versionchanged:: 1.16
This resource has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/
Returns a list of all ticket layouts
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/democon/ticketlayouts/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "Default layout",
"default": true,
"layout": {…},
"background": {},
"item_assignments": []
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
Returns information on layout.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/democon/ticketlayouts/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 1,
"name": "Default layout",
"default": true,
"layout": {…},
"background": {},
"item_assignments": []
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the layout to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.

View File

@@ -1,5 +1,6 @@
addon
addons
anonymize
api
auditability
auth
@@ -16,8 +17,10 @@ checksum
config
contenttypes
contextmanager
cookiecutter
cron
cronjob
cryptographic
debian
deduplication
discoverable
@@ -34,10 +37,13 @@ gettext
gunicorn
hardcoded
hostname
idempotency
incrementing
inofficial
invalidations
iterable
Jimdo
libpretixprint
libsass
linters
memcached
@@ -56,6 +62,7 @@ nginx
NotificationType
ons
optimizations
overpayment
param
percental
positionid
@@ -71,6 +78,8 @@ pretixpresale
prometheus
proxied
proxying
pseudonymize
pseudonymization
queryset
redemptions
redis
@@ -98,6 +107,7 @@ subpath
systemd
testutils
timestamp
tuples
un
unconfigured
unix
@@ -106,6 +116,7 @@ untrusted
username
url
versa
versioning
viewset
viewsets
webhook

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class PretixApiConfig(AppConfig):
name = 'pretix.api'
label = 'pretixapi'
default_app_config = 'pretix.api.PretixApiConfig'

View File

@@ -1,5 +1,6 @@
from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken
from pretix.base.models import Event
from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.helpers.security import (
@@ -55,4 +56,28 @@ class EventPermission(BasePermission):
if required_permission and required_permission not in request.orgapermset:
return False
if isinstance(request.auth, OAuthAccessToken):
if not request.auth.allow_scopes(['write']) and request.method not in SAFE_METHODS:
return False
if not request.auth.allow_scopes(['read']) and request.method in SAFE_METHODS:
return False
if isinstance(request.auth, OAuthAccessToken) and hasattr(request, 'organizer'):
if not request.auth.organizers.filter(pk=request.organizer.pk).exists():
return False
return True
class EventCRUDPermission(EventPermission):
def has_permission(self, request, view):
if not super(EventCRUDPermission, self).has_permission(request, view):
return False
elif view.action == 'create' and 'can_create_events' not in request.orgapermset:
return False
elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset:
return False
elif view.action in ['retrieve', 'update', 'partial_update'] \
and 'can_change_event_settings' not in request.eventpermset:
return False
return True

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-06-04 11:19
from __future__ import unicode_literals
import django.db.models.deletion
import oauth2_provider.generators
import oauth2_provider.validators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OAuthAccessToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='OAuthApplication',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('client_type',
models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
('authorization_grant_type', models.CharField(
choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'),
('password', 'Resource owner password-based'),
('client-credentials', 'Client credentials')], max_length=32)),
('skip_authorization', models.BooleanField(default=False)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255, verbose_name='Application name')),
('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated',
validators=[oauth2_provider.validators.validate_uris],
verbose_name='Redirection URIs')),
('client_id',
models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100,
unique=True, verbose_name='Client ID')),
('client_secret',
models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_secret,
max_length=255, verbose_name='Client secret')),
('active', models.BooleanField(default=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='pretixapi_oauthapplication', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='OAuthGrant',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('code', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('redirect_uri', models.CharField(max_length=255)),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pretixapi_oauthgrant',
to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='OAuthRefreshToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('token', models.CharField(max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('revoked', models.DateTimeField(null=True)),
('access_token',
models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name='pretixapi_oauthrefreshtoken', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='oauthaccesstoken',
name='application',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
migrations.AddField(
model_name='oauthaccesstoken',
name='source_refresh_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='refreshed_access_token',
to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),
),
migrations.AddField(
model_name='oauthaccesstoken',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='pretixapi_oauthaccesstoken', to=settings.AUTH_USER_MODEL),
),
migrations.AlterUniqueTogether(
name='oauthrefreshtoken',
unique_together=set([('token', 'revoked')]),
),
]

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-06-04 11:20
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0001_initial'),
('pretixapi', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='oauthaccesstoken',
name='organizers',
field=models.ManyToManyField(to='pretixbase.Organizer'),
),
migrations.AddField(
model_name='oauthgrant',
name='organizers',
field=models.ManyToManyField(to='pretixbase.Organizer'),
),
]

View File

70
src/pretix/api/models.py Normal file
View File

@@ -0,0 +1,70 @@
from datetime import timedelta
from django.db import models
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from oauth2_provider.generators import (
generate_client_id, generate_client_secret,
)
from oauth2_provider.models import (
AbstractAccessToken, AbstractApplication, AbstractGrant,
AbstractRefreshToken,
)
from oauth2_provider.validators import validate_uris
class OAuthApplication(AbstractApplication):
name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False)
redirect_uris = models.TextField(
blank=False, validators=[validate_uris],
verbose_name=_("Redirection URIs"),
help_text=_("Allowed URIs list, space separated")
)
client_id = models.CharField(
verbose_name=_("Client ID"),
max_length=100, unique=True, default=generate_client_id, db_index=True
)
client_secret = models.CharField(
verbose_name=_("Client secret"),
max_length=255, blank=False, default=generate_client_secret, db_index=True
)
active = models.BooleanField(default=True)
def get_absolute_url(self):
return reverse("control:user.settings.oauth.app", kwargs={'pk': self.id})
def is_usable(self, request):
return self.active and super().is_usable(request)
class OAuthGrant(AbstractGrant):
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE
)
organizers = models.ManyToManyField('pretixbase.Organizer')
class OAuthAccessToken(AbstractAccessToken):
source_refresh_token = models.OneToOneField(
# unique=True implied by the OneToOneField
'OAuthRefreshToken', on_delete=models.SET_NULL, blank=True, null=True,
related_name="refreshed_access_token"
)
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE, blank=True, null=True,
)
organizers = models.ManyToManyField('pretixbase.Organizer')
def revoke(self):
self.expires = now() - timedelta(hours=1)
self.save(update_fields=['expires'])
class OAuthRefreshToken(AbstractRefreshToken):
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE)
access_token = models.OneToOneField(
OAuthAccessToken, on_delete=models.SET_NULL, blank=True, null=True,
related_name="refresh_token"
)

45
src/pretix/api/oauth.py Normal file
View File

@@ -0,0 +1,45 @@
from datetime import timedelta
from django.utils import timezone
from oauth2_provider.exceptions import FatalClientError
from oauth2_provider.oauth2_validators import Grant, OAuth2Validator
from oauth2_provider.settings import oauth2_settings
class Validator(OAuth2Validator):
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
if not getattr(request, 'organizers', None):
raise FatalClientError('No organizers selected.')
expires = timezone.now() + timedelta(
seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS)
g = Grant(application=request.client, user=request.user, code=code["code"],
expires=expires, redirect_uri=request.redirect_uri,
scope=" ".join(request.scopes))
g.save()
g.organizers.add(*request.organizers.all())
def validate_code(self, client_id, code, client, request, *args, **kwargs):
try:
grant = Grant.objects.get(code=code, application=client)
if not grant.is_expired():
request.scopes = grant.scope.split(" ")
request.user = grant.user
request.organizers = grant.organizers.all()
return True
return False
except Grant.DoesNotExist:
return False
def _create_access_token(self, expires, request, token, source_refresh_token=None):
if not getattr(request, 'organizers', None) and not getattr(source_refresh_token, 'access_token'):
raise FatalClientError('No organizers selected.')
if hasattr(request, 'organizers'):
orgs = list(request.organizers.all())
else:
orgs = list(source_refresh_token.access_token.organizers.all())
access_token = super()._create_access_token(expires, request, token, source_refresh_token=None)
access_token.organizers.add(*orgs)
return access_token

View File

@@ -1,3 +1,7 @@
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from django_countries.serializers import CountryFieldMixin
from rest_framework.fields import Field
@@ -14,15 +18,161 @@ class MetaDataField(Field):
v.property.name: v.value for v in value.meta_values.all()
}
def to_internal_value(self, data):
return {
'meta_data': data
}
class PluginsField(Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
return {
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
}
def to_internal_value(self, data):
return {
'plugins': data
}
class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(source='*')
meta_data = MetaDataField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'has_subevents', 'meta_data')
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
Event.clean_dates(data.get('date_from'), data.get('date_to'))
Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
return data
def validate_has_subevents(self, value):
Event.clean_has_subevents(self.instance, value)
return value
def validate_slug(self, value):
Event.clean_slug(self.context['request'].organizer, self.instance, value)
return value
def validate_live(self, value):
if value:
if self.instance is None:
raise ValidationError(_('Events cannot be created as \'live\'. Quotas and payment must be added to the '
'event before sales can go live.'))
else:
self.instance.clean_live()
return value
@cached_property
def meta_properties(self):
return {
p.name: p for p in self.context['request'].organizer.meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.meta_properties:
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
return value
def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
for plugin in value.get('plugins'):
if plugin not in plugins_available:
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
return value
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
event = super().create(validated_data)
# 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
)
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
return event
@transaction.atomic
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
event = super().update(instance, validated_data)
# Meta data
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
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
event.save()
return event
class CloneEventSerializer(EventSerializer):
@transaction.atomic
def create(self, validated_data):
plugins = validated_data.pop('plugins', None)
is_public = validated_data.pop('is_public', None)
new_event = super().create(validated_data)
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
new_event.copy_data_from(event)
if plugins is not None:
new_event.set_active_plugins(plugins)
if is_public is not None:
new_event.is_public = is_public
new_event.save()
return new_event
class SubEventItemSerializer(I18nAwareModelSerializer):

View File

@@ -74,12 +74,12 @@ class ItemSerializer(I18nAwareModelSerializer):
class Meta:
model = Item
fields = ('id', 'category', 'name', 'active', 'description',
fields = ('id', 'category', 'name', 'internal_name', 'active', 'description',
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
'variations', 'addons')
'variations', 'addons', 'original_price')
read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self):
@@ -129,7 +129,7 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta:
model = ItemCategory
fields = ('id', 'name', 'description', 'position', 'is_addon')
fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon')
class QuestionOptionSerializer(I18nAwareModelSerializer):

View File

@@ -1,18 +1,27 @@
import json
from collections import Counter
from decimal import Decimal
from django.utils.timezone import now
from django_countries.fields import Country
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
QuestionAnswer,
Question, QuestionAnswer, Quota,
)
from pretix.base.models.orders import OrderFee
from pretix.base.pdf import get_variables
from pretix.base.signals import register_ticket_outputs
class CompatibleCountryField(serializers.Field):
def to_internal_value(self, data):
return {self.field_name: Country(data)}
def to_representation(self, instance: InvoiceAddress):
if instance.country:
return str(instance.country)
@@ -20,13 +29,20 @@ class CompatibleCountryField(serializers.Field):
return instance.country_old
class InvoiceAdddressSerializer(I18nAwareModelSerializer):
class InvoiceAddressSerializer(I18nAwareModelSerializer):
country = CompatibleCountryField(source='*')
class Meta:
model = InvoiceAddress
fields = ('last_modified', 'is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
'vat_id_validated', 'internal_reference')
read_only_fields = ('last_modified', 'vat_id_validated')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for v in self.fields.values():
v.required = False
v.allow_blank = True
class AnswerQuestionIdentifierField(serializers.Field):
@@ -104,17 +120,39 @@ class PositionDownloadsField(serializers.Field):
return res
class PdfDataSerializer(serializers.Field):
def to_representation(self, instance: OrderPosition):
res = {}
ev = instance.subevent or instance.order.event
pdfvars = get_variables(instance.order.event)
for k, f in pdfvars.items():
res[k] = f['evaluate'](instance, instance.order, ev)
for k, v in ev.meta_data.items():
res['meta:' + k] = v
return res
class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True)
answers = AnswerSerializer(many=True)
downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*')
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'downloads',
'answers', 'tax_rule')
'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
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')
class OrderFeeSerializer(I18nAwareModelSerializer):
@@ -123,32 +161,297 @@ class OrderFeeSerializer(I18nAwareModelSerializer):
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
class PaymentFeeLegacyField(serializers.Field):
def __init__(self, *args, **kwargs):
self.attr = kwargs.pop('attribute')
super().__init__(*args, **kwargs)
def to_representation(self, instance: Order):
return str(
sum([getattr(f, self.attr) for f in instance.fees.all() if f.fee_type == OrderFee.FEE_TYPE_PAYMENT],
Decimal('0.00'))
)
class OrderSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAdddressSerializer()
invoice_address = InvoiceAddressSerializer()
positions = OrderPositionSerializer(many=True)
fees = OrderFeeSerializer(many=True)
downloads = OrderDownloadsField(source='*')
payment_fee = PaymentFeeLegacyField(source='*', attribute='value') # TODO: Remove in 1.9
payment_fee_tax_rate = PaymentFeeLegacyField(source='*', attribute='tax_rate') # TODO: Remove in 1.9
payment_fee_tax_value = PaymentFeeLegacyField(source='*', attribute='tax_value') # TODO: Remove in 1.9
class Meta:
model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value', 'checkin_attention')
'checkin_attention', 'last_modified')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields['positions'].child.fields.pop('pdf_data')
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 OrderFeeCreateSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderFee
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule')
def validate_tax_rule(self, tr):
if tr and tr.event != self.context['event']:
raise ValidationError(
'The specified tax rate does not belong to this event.'
)
return tr
class OrderPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers')
def validate_secret(self, secret):
if secret and OrderPosition.objects.filter(order__event=self.context['event'], secret=secret).exists():
raise ValidationError(
'You cannot assign a position secret that already exists.'
)
return secret
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
'The specified item does not belong to this event.'
)
if not item.active:
raise ValidationError(
'The specified item is not active.'
)
return item
def validate_subevent(self, subevent):
if self.context['event'].has_subevents:
if not subevent:
raise ValidationError(
'You need to set a subevent.'
)
if subevent.event != self.context['event']:
raise ValidationError(
'The specified subevent does not belong to this event.'
)
elif subevent:
raise ValidationError(
'You cannot set a subevent for this event.'
)
return subevent
def validate(self, data):
if data.get('item'):
if data.get('item').has_variations:
if not data.get('variation'):
raise ValidationError('You should specify a variation for this item.')
else:
if data.get('variation').item != data.get('item'):
raise ValidationError(
'The specified variation does not belong to the specified item.'
)
elif data.get('variation'):
raise ValidationError(
'You cannot specify a variation for this item.'
)
return data
class CompatibleJSONField(serializers.JSONField):
def to_internal_value(self, data):
try:
return json.dumps(data)
except (TypeError, ValueError):
self.fail('invalid')
def to_representation(self, value):
if value:
return json.loads(value)
return value
class OrderCreateSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer(required=False)
positions = OrderPositionCreateSerializer(many=True, required=False)
fees = OrderFeeCreateSerializer(many=True, required=False)
status = serializers.ChoiceField(choices=(
('n', Order.STATUS_PENDING),
('p', Order.STATUS_PAID),
), default='n', required=False)
code = serializers.CharField(
required=False,
max_length=16,
min_length=5
)
comment = serializers.CharField(required=False, allow_blank=True)
payment_provider = serializers.CharField(required=True)
payment_info = CompatibleJSONField(required=False)
class Meta:
model = Order
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment',
'invoice_address', 'positions', 'checkin_attention', 'payment_info')
def validate_payment_provider(self, pp):
if pp not in self.context['event'].get_payment_providers():
raise ValidationError('The given payment provider is not known.')
return pp
def validate_code(self, code):
if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists():
raise ValidationError(
'This order code is already in use.'
)
if any(c not in 'ABCDEFGHJKLMNPQRSTUVWXYZ1234567890' for c in code):
raise ValidationError(
'This order code contains invalid characters.'
)
return code
def validate_positions(self, data):
if not data:
raise ValidationError(
'An order cannot be empty.'
)
if any([p.get('positionid') for p in data]):
if not all([p.get('positionid') for p in data]):
raise ValidationError(
'If you set position IDs manually, you need to do so for all positions.'
)
last_non_add_on = None
last_posid = 0
for p in data:
if p['positionid'] != last_posid + 1:
raise ValidationError("Position IDs need to be consecutive.")
if p.get('addon_to') and p['addon_to'] != last_non_add_on:
raise ValidationError("If you set addon_to, you need to make sure that the referenced "
"position ID exists and is transmitted directly before its add-ons.")
if not p.get('addon_to'):
last_non_add_on = p['positionid']
last_posid = p['positionid']
elif any([p.get('addon_to') for p in data]):
raise ValidationError("If you set addon_to, you need to specify position IDs manually.")
return data
def create(self, validated_data):
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
if 'invoice_address' in validated_data:
ia = InvoiceAddress(**validated_data.pop('invoice_address'))
else:
ia = None
with self.context['event'].lock():
quotadiff = Counter()
for pos_data in positions_data:
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
quotadiff.update(new_quotas)
for quota, diff in quotadiff.items():
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
raise ValidationError(
'There is not enough quota available on quota "{}" to perform the operation.'.format(
quota.name
)
)
order = Order(event=self.context['event'], **validated_data)
order.set_expires(subevents=[p['subevent'] for p in positions_data])
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.payment_provider = 'free'
order.status = Order.STATUS_PAID
elif order.payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
if validated_data.get('status') == Order.STATUS_PAID:
order.payment_date = now()
order.save()
if ia:
ia.order = order
ia.save()
pos_map = {}
for pos_data in positions_data:
answers_data = pos_data.pop('answers')
addon_to = pos_data.pop('addon_to')
pos = OrderPosition(**pos_data)
pos.order = order
pos._calculate_tax()
if addon_to:
pos.addon_to = pos_map[addon_to]
pos.save()
pos_map[pos.positionid] = pos
for answ_data in answers_data:
options = answ_data.pop('options')
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
for fee_data in fees_data:
f = OrderFee(**fee_data)
f.order = order
f._calculate_tax()
f.save()
return order
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):

View File

@@ -1,3 +1,5 @@
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import WaitingListEntry
@@ -7,3 +9,27 @@ class WaitingListSerializer(I18nAwareModelSerializer):
class Meta:
model = WaitingListEntry
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent')
read_only_fields = ('id', 'created', 'voucher')
def validate(self, data):
data = super().validate(data)
event = self.context['event']
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
WaitingListEntry.clean_duplicate(full_data.get('email'), full_data.get('item'), full_data.get('variation'),
full_data.get('subevent'), self.instance.pk if self.instance else None)
WaitingListEntry.clean_itemvar(event, full_data.get('item'), full_data.get('variation'))
WaitingListEntry.clean_subevent(event, full_data.get('subevent'))
if 'item' in data or 'variation' in data:
availability = (
full_data.get('variation').check_quotas(count_waitinglist=True, subevent=full_data.get('subevent'))
if full_data.get('variation')
else full_data.get('item').check_quotas(count_waitinglist=True, subevent=full_data.get('subevent'))
)
if availability[0] == 100:
raise ValidationError("This product is currently available.")
return data

View File

@@ -4,7 +4,9 @@ from django.apps import apps
from django.conf.urls import include, url
from rest_framework import routers
from .views import checkin, event, item, order, organizer, voucher, waitinglist
from .views import (
checkin, event, item, oauth, order, organizer, voucher, waitinglist,
)
router = routers.DefaultRouter()
router.register(r'organizers', organizer.OrganizerViewSet)
@@ -14,6 +16,7 @@ orga_router.register(r'events', event.EventViewSet)
event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet)
event_router.register(r'clone', event.CloneEventViewSet)
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'questions', item.QuestionViewSet)
@@ -51,4 +54,7 @@ urlpatterns = [
include(question_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
include(checkinlist_router.urls)),
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),
]

View File

@@ -1,3 +1,8 @@
from calendar import timegm
from django.db.models import Max
from django.http import HttpResponse
from django.utils.http import http_date, parse_http_date_safe
from rest_framework.filters import OrderingFilter
@@ -21,3 +26,33 @@ class RichOrderingFilter(OrderingFilter):
return queryset.order_by(*ordering)
return queryset
class ConditionalListView:
def list(self, request, **kwargs):
if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE')
if if_modified_since:
if_modified_since = parse_http_date_safe(if_modified_since)
if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
if if_unmodified_since:
if_unmodified_since = parse_http_date_safe(if_unmodified_since)
lmd = request.event.logentry_set.filter(
content_type__model=self.queryset.model._meta.model_name,
content_type__app_label=self.queryset.model._meta.app_label,
).aggregate(
m=Max('datetime')
)['m']
if lmd:
lmd_ts = timegm(lmd.utctimetuple())
if if_unmodified_since and lmd and lmd_ts > if_unmodified_since:
return HttpResponse(status=412)
if if_modified_since and lmd and lmd_ts <= if_modified_since:
return HttpResponse(status=304)
resp = super().list(request, **kwargs)
if lmd:
resp['Last-Modified'] = http_date(lmd_ts)
return resp

View File

@@ -1,16 +1,24 @@
import django_filters
from django.db.models import F, Max, OuterRef, Prefetch, Q, Subquery
from django.core.exceptions import ValidationError
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import detail_route
from rest_framework.fields import DateTimeField
from rest_framework.response import Response
from pretix.api.serializers.checkin import CheckinListSerializer
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, perform_checkin,
)
from pretix.helpers.database import FixedOrderBy
@@ -40,7 +48,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.checkinlist.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -54,7 +62,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.checkinlist.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -62,26 +70,84 @@ class CheckinListViewSet(viewsets.ModelViewSet):
instance.log_action(
'pretix.event.checkinlist.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
super().perform_destroy(instance)
@detail_route(methods=['GET'])
def status(self, *args, **kwargs):
clist = self.get_object()
cqs = Checkin.objects.filter(
position__order__event=clist.event,
position__order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
list=clist
)
pqs = OrderPosition.objects.filter(
order__event=clist.event,
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
subevent=clist.subevent,
)
if not clist.all_products:
pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True))
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(name='order', lookup_expr='code')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
ev = clist.subevent or clist.event
response = {
'event': {
'name': str(ev.name),
},
'checkin_count': cqs.count(),
'position_count': pqs.count()
}
op_by_item = {
p['item']: p['cnt']
for p in pqs.order_by().values('item').annotate(cnt=Count('id'))
}
op_by_variation = {
p['variation']: p['cnt']
for p in pqs.order_by().values('variation').annotate(cnt=Count('id'))
}
c_by_item = {
p['position__item']: p['cnt']
for p in cqs.order_by().values('position__item').annotate(cnt=Count('id'))
}
c_by_variation = {
p['position__variation']: p['cnt']
for p in cqs.order_by().values('position__variation').annotate(cnt=Count('id'))
}
if not clist.all_products:
items = clist.limit_products
else:
items = clist.event.items
response['items'] = []
for item in items.order_by('category__position', 'position', 'pk').prefetch_related('variations'):
i = {
'id': item.pk,
'name': str(item),
'admission': item.admission,
'checkin_count': c_by_item.get(item.pk, 0),
'position_count': op_by_item.get(item.pk, 0),
'variations': []
}
for var in item.variations.all():
i['variations'].append({
'id': var.pk,
'value': str(var),
'checkin_count': c_by_variation.get(var.pk, 0),
'position_count': op_by_variation.get(var.pk, 0),
})
response['items'].append(i)
return Response(response)
class CheckinOrderPositionFilter(OrderPositionFilter):
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value)
def attendee_name_qs(self, queryset, name, value):
return queryset.filter(Q(attendee_name=value) | Q(addon_to__attendee_name=value))
class Meta:
model = OrderPosition
fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'has_checkin', 'addon_to', 'subevent']
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
@@ -109,8 +175,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
},
}
filter_class = OrderPositionFilter
filter_class = CheckinOrderPositionFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
@cached_property
def checkinlist(self):
@@ -141,3 +208,53 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
return qs
@detail_route(methods=['POST'])
def redeem(self, *args, **kwargs):
force = bool(self.request.data.get('force', False))
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce')
op = self.get_object()
if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
else:
dt = now()
given_answers = {}
if 'answers' in self.request.data:
aws = self.request.data.get('answers')
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)])
except ValidationError:
pass
try:
perform_checkin(
op=op,
clist=self.checkinlist,
given_answers=given_answers,
force=force,
ignore_unpaid=ignore_unpaid,
nonce=nonce,
datetime=dt,
questions_supported=self.request.data.get('questions_supported', True)
)
except RequiredQuestionsError as e:
return Response({
'status': 'incomplete',
'questions': [
QuestionSerializer(q).data for q in e.questions
]
}, status=400)
except CheckInError as e:
return Response({
'status': 'error',
'reason': e.code
}, status=400)
else:
return Response({
'status': 'ok',
}, status=201)

View File

@@ -1,24 +1,123 @@
from django.db import transaction
from django.db.models import ProtectedError
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied
from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.serializers.event import (
EventSerializer, SubEventSerializer, TaxRuleSerializer,
CloneEventSerializer, EventSerializer, SubEventSerializer,
TaxRuleSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import Event, ItemCategory, TaxRule
from pretix.base.models.event import SubEvent
from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.dicts import merge_dicts
class EventViewSet(viewsets.ReadOnlyModelViewSet):
class EventViewSet(viewsets.ModelViewSet):
serializer_class = EventSerializer
queryset = Event.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'event'
permission_classes = (EventCRUDPermission,)
def get_queryset(self):
return self.request.organizer.events.prefetch_related('meta_values', 'meta_values__property')
def perform_update(self, serializer):
current_live_value = serializer.instance.live
updated_live_value = serializer.validated_data.get('live', None)
current_plugins_value = serializer.instance.get_plugins()
updated_plugins_value = serializer.validated_data.get('plugins', None)
super().perform_update(serializer)
if updated_live_value is not None and updated_live_value != current_live_value:
log_action = 'pretix.event.live.activated' if updated_live_value else 'pretix.event.live.deactivated'
serializer.instance.log_action(
log_action,
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
if updated_plugins_value is not None and set(updated_plugins_value) != set(current_plugins_value):
enabled = {m: 'enabled' for m in updated_plugins_value if m not in current_plugins_value}
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled)
for module, action in changed.items():
serializer.instance.log_action(
'pretix.event.plugins.' + action,
user=self.request.user,
auth=self.request.auth,
data={'plugin': module}
)
other_keys = {k: v for k, v in serializer.validated_data.items() if k not in ['plugins', 'live']}
if other_keys:
serializer.instance.log_action(
'pretix.event.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_create(self, serializer):
serializer.save(organizer=self.request.organizer)
serializer.instance.log_action(
'pretix.event.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\''
' to false to hide the event and take the shop offline instead.')
try:
with transaction.atomic():
instance.organizer.log_action(
'pretix.event.deleted', user=self.request.user,
data={
'event_id': instance.pk,
'name': str(instance.name),
'logentries': list(instance.logentry_set.values_list('pk', flat=True))
}
)
instance.delete_sub_objects()
super().perform_destroy(instance)
except ProtectedError:
raise PermissionDenied('The event could not be deleted as some constraints (e.g. data created by plug-ins) '
'do not allow it.')
class CloneEventViewSet(viewsets.ModelViewSet):
serializer_class = CloneEventSerializer
queryset = Event.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'event'
http_method_names = ['post']
write_permission = 'can_create_events'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.kwargs['event']
ctx['organizer'] = self.request.organizer
return ctx
def perform_create(self, serializer):
serializer.save(organizer=self.request.organizer)
serializer.instance.log_action(
'pretix.event.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
class SubEventFilter(FilterSet):
class Meta:
@@ -26,7 +125,7 @@ class SubEventFilter(FilterSet):
fields = ['active']
class SubEventViewSet(viewsets.ReadOnlyModelViewSet):
class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
serializer_class = SubEventSerializer
queryset = ItemCategory.objects.none()
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
@@ -38,7 +137,7 @@ class SubEventViewSet(viewsets.ReadOnlyModelViewSet):
)
class TaxRuleViewSet(viewsets.ModelViewSet):
class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = TaxRuleSerializer
queryset = TaxRule.objects.none()
write_permission = 'can_change_event_settings'
@@ -51,7 +150,7 @@ class TaxRuleViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.taxrule.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -60,7 +159,7 @@ class TaxRuleViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.taxrule.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -71,6 +170,6 @@ class TaxRuleViewSet(viewsets.ModelViewSet):
instance.log_action(
'pretix.event.taxrule.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
super().perform_destroy(instance)

View File

@@ -13,11 +13,11 @@ from pretix.api.serializers.item import (
ItemVariationSerializer, QuestionOptionSerializer, QuestionSerializer,
QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.dicts import merge_dicts
@@ -35,7 +35,7 @@ class ItemFilter(FilterSet):
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
class ItemViewSet(viewsets.ModelViewSet):
class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = ItemSerializer
queryset = Item.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -53,7 +53,7 @@ class ItemViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.item.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -68,7 +68,7 @@ class ItemViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.item.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -81,7 +81,7 @@ class ItemViewSet(viewsets.ModelViewSet):
instance.log_action(
'pretix.event.item.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
super().perform_destroy(instance)
@@ -113,7 +113,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
item.log_action(
'pretix.event.item.variation.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk},
{'value': serializer.instance.value})
)
@@ -123,7 +123,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
serializer.instance.item.log_action(
'pretix.event.item.variation.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk},
{'value': serializer.instance.value})
)
@@ -140,7 +140,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
instance.item.log_action(
'pretix.event.item.variation.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data={
'value': instance.value,
'id': self.kwargs['pk']
@@ -174,7 +174,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
item.log_action(
'pretix.event.item.addons.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
@@ -183,7 +183,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
serializer.instance.base_item.log_action(
'pretix.event.item.addons.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
@@ -192,7 +192,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
instance.base_item.log_action(
'pretix.event.item.addons.removed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data={'category': instance.addon_category.pk}
)
@@ -203,7 +203,7 @@ class ItemCategoryFilter(FilterSet):
fields = ['is_addon']
class ItemCategoryViewSet(viewsets.ModelViewSet):
class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = ItemCategorySerializer
queryset = ItemCategory.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -221,7 +221,7 @@ class ItemCategoryViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.category.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -235,7 +235,7 @@ class ItemCategoryViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.category.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -246,15 +246,22 @@ class ItemCategoryViewSet(viewsets.ModelViewSet):
instance.log_action(
'pretix.event.category.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
super().perform_destroy(instance)
class QuestionViewSet(viewsets.ModelViewSet):
class QuestionFilter(FilterSet):
class Meta:
model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionSerializer
queryset = Question.objects.none()
filter_backends = (OrderingFilter,)
filter_backends = (DjangoFilterBackend, OrderingFilter)
filter_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = 'can_change_items'
@@ -267,7 +274,7 @@ class QuestionViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.question.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -281,7 +288,7 @@ class QuestionViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.question.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -289,7 +296,7 @@ class QuestionViewSet(viewsets.ModelViewSet):
instance.log_action(
'pretix.event.question.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
super().perform_destroy(instance)
@@ -319,7 +326,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
q.log_action(
'pretix.event.question.option.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
@@ -328,7 +335,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
serializer.instance.question.log_action(
'pretix.event.question.option.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
@@ -336,7 +343,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
instance.question.log_action(
'pretix.event.question.option.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data={'id': instance.pk}
)
super().perform_destroy(instance)
@@ -348,7 +355,7 @@ class QuotaFilter(FilterSet):
fields = ['subevent']
class QuotaViewSet(viewsets.ModelViewSet):
class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuotaSerializer
queryset = Quota.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter,)
@@ -366,14 +373,14 @@ class QuotaViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.quota.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
if serializer.instance.subevent:
serializer.instance.subevent.log_action(
'pretix.subevent.quota.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -389,7 +396,7 @@ class QuotaViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.event.quota.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
if current_subevent == request_subevent:
@@ -397,7 +404,7 @@ class QuotaViewSet(viewsets.ModelViewSet):
current_subevent.log_action(
'pretix.subevent.quota.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
else:
@@ -405,14 +412,14 @@ class QuotaViewSet(viewsets.ModelViewSet):
request_subevent.log_action(
'pretix.subevent.quota.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
if current_subevent is not None:
current_subevent.log_action(
'pretix.subevent.quota.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
serializer.instance.rebuild_cache()
@@ -420,13 +427,13 @@ class QuotaViewSet(viewsets.ModelViewSet):
instance.log_action(
'pretix.event.quota.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
if instance.subevent:
instance.subevent.log_action(
'pretix.subevent.quota.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
super().perform_destroy(instance)

View File

@@ -0,0 +1,92 @@
import logging
from django import forms
from django.conf import settings
from django.utils.translation import ugettext as _
from oauth2_provider.exceptions import OAuthToolkitError
from oauth2_provider.forms import AllowForm
from oauth2_provider.views import (
AuthorizationView as BaseAuthorizationView,
RevokeTokenView as BaseRevokeTokenView, TokenView as BaseTokenView,
)
from pretix.api.models import OAuthApplication
from pretix.base.models import Organizer
logger = logging.getLogger(__name__)
class OAuthAllowForm(AllowForm):
organizers = forms.ModelMultipleChoiceField(
queryset=Organizer.objects.none(),
widget=forms.CheckboxSelectMultiple
)
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['organizers'].queryset = Organizer.objects.filter(
pk__in=user.teams.values_list('organizer', flat=True))
class AuthorizationView(BaseAuthorizationView):
template_name = "pretixcontrol/auth/oauth_authorization.html"
form_class = OAuthAllowForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['settings'] = settings
return ctx
def create_authorization_response(self, request, scopes, credentials, allow, organizers):
credentials["organizers"] = organizers
return super().create_authorization_response(request, scopes, credentials, allow)
def form_valid(self, form):
client_id = form.cleaned_data["client_id"]
application = OAuthApplication.objects.get(client_id=client_id)
credentials = {
"client_id": form.cleaned_data.get("client_id"),
"redirect_uri": form.cleaned_data.get("redirect_uri"),
"response_type": form.cleaned_data.get("response_type", None),
"state": form.cleaned_data.get("state", None),
}
scopes = form.cleaned_data.get("scope")
allow = form.cleaned_data.get("allow")
try:
uri, headers, body, status = self.create_authorization_response(
request=self.request, scopes=scopes, credentials=credentials, allow=allow,
organizers=form.cleaned_data.get("organizers")
)
except OAuthToolkitError as error:
return self.error_response(error, application)
self.success_url = uri
logger.debug("Success url for the request: {0}".format(self.success_url))
msgs = [
_('The application "{application_name}" has been authorized to access your account.').format(
application_name=application.name
)
]
self.request.user.send_security_notice(msgs)
self.request.user.log_action('pretix.user.oauth.authorized', user=self.request.user, data={
'application_id': application.pk,
'application_name': application.name,
})
return self.redirect(self.success_url, application)
class TokenView(BaseTokenView):
pass
class RevokeTokenView(BaseRevokeTokenView):
pass

View File

@@ -2,41 +2,56 @@ import datetime
import django_filters
import pytz
from django.db import transaction
from django.db.models import Q
from django.db.models.functions import Concat
from django.http import FileResponse
from django.utils.timezone import make_aware
from django.utils.timezone import make_aware, now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import detail_route
from rest_framework.exceptions import APIException, NotFound, PermissionDenied
from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
)
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin
from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import (
InvoiceSerializer, OrderPositionSerializer, OrderSerializer,
InvoiceSerializer, OrderCreateSerializer, OrderPositionSerializer,
OrderSerializer,
)
from pretix.base.models import (
Invoice, Order, OrderPosition, Quota, TeamAPIToken,
)
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice,
)
from pretix.base.models import Invoice, Order, OrderPosition, Quota
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.services.invoices import invoice_pdf
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import (
OrderError, cancel_order, extend_order, mark_order_expired,
mark_order_paid,
mark_order_paid, mark_order_refunded,
)
from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position,
)
from pretix.base.signals import register_ticket_outputs
from pretix.base.signals import order_placed, register_ticket_outputs
class OrderFilter(FilterSet):
email = django_filters.CharFilter(name='email', lookup_expr='iexact')
code = django_filters.CharFilter(name='code', lookup_expr='iexact')
status = django_filters.CharFilter(name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(name='last_modified', lookup_expr='gte')
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale']
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -47,6 +62,11 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def get_queryset(self):
return self.request.event.orders.prefetch_related(
'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options',
@@ -63,6 +83,20 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
return prov
raise NotFound('Unknown output provider.')
def list(self, request, **kwargs):
date = serializers.DateTimeField().to_representation(now())
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
resp = self.get_paginated_response(serializer.data)
resp['X-Page-Generated'] = date
return resp
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date})
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
@@ -92,7 +126,7 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
mark_order_paid(
order, manual=True,
user=request.user if request.user.is_authenticated else None,
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
auth=request.auth,
)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@@ -119,7 +153,8 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
cancel_order(
order,
user=request.user if request.user.is_authenticated else None,
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail
)
return self.retrieve(request, [], **kwargs)
@@ -140,7 +175,7 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
order.log_action(
'pretix.event.order.unpaid',
user=request.user if request.user.is_authenticated else None,
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
auth=request.auth,
)
return self.retrieve(request, [], **kwargs)
@@ -157,11 +192,26 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
mark_order_expired(
order,
user=request.user if request.user.is_authenticated else None,
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
auth=request.auth,
)
return self.retrieve(request, [], **kwargs)
# TODO: Find a way to implement mark_refunded
@detail_route(methods=['POST'])
def mark_refunded(self, request, **kwargs):
order = self.get_object()
if order.status != Order.STATUS_PAID:
return Response(
{'detail': 'The order is not paid.'},
status=status.HTTP_400_BAD_REQUEST
)
mark_order_refunded(
order,
user=request.user if request.user.is_authenticated else None,
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def extend(self, request, **kwargs):
@@ -196,7 +246,7 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
new_date=new_date,
force=force,
user=request.user if request.user.is_authenticated else None,
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
auth=request.auth,
)
return self.retrieve(request, [], **kwargs)
except OrderError as e:
@@ -205,22 +255,66 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
def create(self, request, *args, **kwargs):
serializer = OrderCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic():
self.perform_create(serializer)
order = serializer.instance
serializer = OrderSerializer(order, context=serializer.context)
order.log_action(
'pretix.event.order.placed',
user=request.user if request.user.is_authenticated else None,
auth=request.auth,
)
order_placed.send(self.request.event, order=order)
gen_invoice = invoice_qualified(order) and (
(order.event.settings.get('invoice_generate') == 'True') or
(order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID)
) and not order.invoices.last()
if gen_invoice:
generate_invoice(order, trigger_pdf=True)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
serializer.save()
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(name='order', lookup_expr='code')
order = django_filters.CharFilter(name='order', lookup_expr='code__iexact')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value):
return queryset.filter(
Q(secret__istartswith=value)
| Q(attendee_name__icontains=value)
| Q(addon_to__attendee_name__icontains=value)
| Q(order__code__istartswith=value)
| Q(order__invoice_address__name__icontains=value)
)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not value)
def attendee_name_qs(self, queryset, name, value):
return queryset.filter(Q(attendee_name=value) | Q(addon_to__attendee_name=value))
return queryset.filter(Q(attendee_name__iexact=value) | Q(addon_to__attendee_name__iexact=value))
class Meta:
model = OrderPosition
fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'order__status', 'has_checkin',
'addon_to', 'subevent']
fields = {
'item': ['exact', 'in'],
'variation': ['exact', 'in'],
'secret': ['exact'],
'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in']
}
class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
@@ -306,6 +400,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
permission = 'can_view_orders'
lookup_url_kwarg = 'number'
lookup_field = 'nr'
write_permission = 'can_change_orders'
def get_queryset(self):
return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate(
@@ -320,9 +415,54 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
invoice_pdf(invoice.pk)
invoice.refresh_from_db()
if invoice.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
if not invoice.file:
raise RetryException()
resp = FileResponse(invoice.file.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
@detail_route(methods=['POST'])
def regenerate(self, request, **kwarts):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
elif inv.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
else:
inv = regenerate_invoice(inv)
inv.order.log_action(
'pretix.event.order.invoice.regenerated',
data={
'invoice': inv.pk
},
user=self.request.user,
auth=self.request.auth,
)
return Response(status=204)
@detail_route(methods=['POST'])
def reissue(self, request, **kwarts):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
elif inv.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
else:
c = generate_cancellation(inv)
if inv.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
inv = generate_invoice(inv.order)
else:
inv = c
inv.order.log_action(
'pretix.event.order.invoice.reissued',
data={
'invoice': inv.pk
},
user=self.request.user,
auth=self.request.auth,
)
return Response(status=204)

View File

@@ -1,5 +1,6 @@
from rest_framework import viewsets
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import OrganizerSerializer
from pretix.base.models import Organizer
@@ -14,6 +15,12 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
if self.request.user.is_authenticated():
if self.request.user.has_active_staff_session(self.request.session.session_key):
return Organizer.objects.all()
elif isinstance(self.request.auth, OAuthAccessToken):
return Organizer.objects.filter(
pk__in=self.request.user.teams.values_list('organizer', flat=True)
).filter(
pk__in=self.request.auth.organizers.values_list('pk', flat=True)
)
else:
return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True))
else:

View File

@@ -9,7 +9,6 @@ from rest_framework.filters import OrderingFilter
from pretix.api.serializers.voucher import VoucherSerializer
from pretix.base.models import Voucher
from pretix.base.models.organizer import TeamAPIToken
class VoucherFilter(FilterSet):
@@ -51,7 +50,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.voucher.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -69,7 +68,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
serializer.instance.log_action(
'pretix.voucher.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
data=self.request.data
)
@@ -80,6 +79,6 @@ class VoucherViewSet(viewsets.ModelViewSet):
instance.log_action(
'pretix.voucher.deleted',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
auth=self.request.auth,
)
super().perform_destroy(instance)

View File

@@ -1,10 +1,14 @@
import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.models import WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException
class WaitingListFilter(FilterSet):
@@ -18,7 +22,7 @@ class WaitingListFilter(FilterSet):
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
class WaitingListViewSet(viewsets.ReadOnlyModelViewSet):
class WaitingListViewSet(viewsets.ModelViewSet):
serializer_class = WaitingListSerializer
queryset = WaitingListEntry.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -26,6 +30,53 @@ class WaitingListViewSet(viewsets.ReadOnlyModelViewSet):
ordering_fields = ('id', 'created', 'email', 'item')
filter_class = WaitingListFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_queryset(self):
return self.request.event.waitinglistentries.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.orders.waitinglist.added',
user=self.request.user,
auth=self.request.auth,
)
def perform_update(self, serializer):
if serializer.instance.voucher:
raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.')
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.orders.waitinglist.changed',
user=self.request.user,
auth=self.request.auth,
)
def perform_destroy(self, instance):
if instance.voucher:
raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.')
instance.log_action(
'pretix.event.orders.waitinglist.deleted',
user=self.request.user,
auth=self.request.auth,
)
super().perform_destroy(instance)
@detail_route(methods=['POST'])
def send_voucher(self, *args, **kwargs):
try:
self.get_object().send_voucher(
user=self.request.user,
auth=self.request.auth,
)
except WaitingListException as e:
raise ValidationError(str(e))
else:
return Response(status=204)

View File

@@ -3,9 +3,9 @@ from decimal import ROUND_HALF_UP, Decimal
from django.conf import settings
def round_decimal(dec, currency=None):
def round_decimal(dec, currency=None, places_dict=settings.CURRENCY_PLACES):
if currency:
places = settings.CURRENCY_PLACES.get(currency, 2)
places = places_dict.get(currency, 2)
return Decimal(dec).quantize(
Decimal('1') / 10 ** places, ROUND_HALF_UP
)

View File

@@ -18,7 +18,7 @@ class InvoiceExporter(BaseExporter):
verbose_name = _('All invoices')
def render(self, form_data: dict):
qs = self.event.invoices.all()
qs = self.event.invoices.filter(shredded=False)
if form_data.get('payment_provider'):
qs = qs.filter(order__payment_provider=form_data.get('payment_provider'))
@@ -38,12 +38,19 @@ class InvoiceExporter(BaseExporter):
with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs:
if not i.file:
try:
if not i.file:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), 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())
i.file.close()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
i.file.close()
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()

View File

@@ -24,13 +24,15 @@ class JSONExporter(BaseExporter):
'categories': [
{
'id': category.id,
'name': str(category.name)
'name': str(category.name),
'internal_name': category.internal_name
} for category in self.event.categories.all()
],
'items': [
{
'id': item.id,
'name': str(item.name),
'internal_name': str(item.internal_name),
'category': item.category_id,
'price': item.default_price,
'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'),

View File

@@ -63,7 +63,7 @@ class OrderListExporter(BaseExporter):
headers = [
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
_('Payment date'), _('Payment type'), _('Fees'),
_('Payment date'), _('Payment type'), _('Fees'), _('Order locale')
]
for tr in tax_rates:
@@ -123,7 +123,8 @@ class OrderListExporter(BaseExporter):
row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
provider_names.get(order.payment_provider, order.payment_provider),
localize(full_fee_sum_cache.get(order.id) or Decimal('0.00'))
localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')),
order.locale,
]
for tr in tax_rates:

View File

@@ -242,3 +242,12 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'resolve this manually.'))
else:
self.instance.vat_id_validated = False
class BaseInvoiceNameForm(BaseInvoiceAddressForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for f in list(self.fields.keys()):
if f != 'name':
del self.fields[f]

View File

@@ -29,7 +29,7 @@ class PlaceholderValidator(BaseValidator):
code='invalid',
)
data_placeholders = list(re.findall(r'({[\w\s]*})', value, re.X))
data_placeholders = list(re.findall(r'({[^}]*})', value, re.X))
invalid_placeholders = []
for placeholder in data_placeholders:
if placeholder not in self.limit_value:

View File

@@ -1,3 +1,4 @@
import logging
from collections import defaultdict
from decimal import Decimal
from io import BytesIO
@@ -8,6 +9,7 @@ from django.contrib.staticfiles import finders
from django.dispatch import receiver
from django.utils.formats import date_format, localize
from django.utils.translation import pgettext
from PIL.Image import BICUBIC
from reportlab.lib import pagesizes
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
@@ -26,6 +28,8 @@ from pretix.base.models import Event, Invoice
from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter
logger = logging.getLogger(__name__)
class BaseInvoiceRenderer:
"""
@@ -178,6 +182,14 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
return 'invoice.pdf', 'application/pdf', buffer.read()
class ThumbnailingImageReader(ImageReader):
def resize(self, width, height, dpi):
self._image.thumbnail(
size=(int(width * dpi / 72), int(height * dpi / 72)),
resample=BICUBIC
)
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
identifier = 'classic'
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
@@ -276,22 +288,42 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.event.settings.invoice_logo_image:
logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
canvas.drawImage(ImageReader(logo_file),
ir = ThumbnailingImageReader(logo_file)
try:
ir.resize(25 * mm, 25 * mm, 300)
except:
logger.exception("Can not resize image")
pass
canvas.drawImage(ir,
95 * mm, (297 - 38) * mm,
width=25 * mm, height=25 * mm,
preserveAspectRatio=True, anchor='n',
mask='auto')
if self.invoice.event.settings.show_date_to:
p_str = (
str(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=self.invoice.event.get_date_from_display(),
to_date=self.invoice.event.get_date_to_display())
)
def shorten(txt):
txt = str(txt)
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(65 * mm, 50 * mm)
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(65 * mm, 50 * mm)
return txt
if not self.invoice.event.has_subevents:
if self.invoice.event.settings.show_date_to:
p_str = (
shorten(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=self.invoice.event.get_date_from_display(),
to_date=self.invoice.event.get_date_to_display())
)
else:
p_str = (
shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display()
)
else:
p_str = (
str(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display()
)
p_str = shorten(self.invoice.event.name)
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 65 * mm, 50 * mm)

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.10 on 2018-03-15 13:22
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0088_auto_20180328_1217'),
]
operations = [
migrations.AddField(
model_name='logentry',
name='shredded',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='invoice',
name='shredded',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-05-09 09:17
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0089_auto_20180315_1322'),
]
operations = [
migrations.AddField(
model_name='item',
name='internal_name',
field=models.CharField(blank=True,
help_text='If you set this, this will be used instead of the public name in the '
'backend.',
max_length=255, null=True, verbose_name='Internal name'),
),
migrations.AddField(
model_name='itemcategory',
name='internal_name',
field=models.CharField(blank=True,
help_text='If you set this, this will be used instead of the public name in the backend.',
max_length=255, null=True, verbose_name='Internal name'),
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-05-13 16:41
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0090_auto_20180509_0917'),
]
operations = [
migrations.AddField(
model_name='order',
name='last_modified',
field=models.DateTimeField(auto_now=True, db_index=True),
),
]

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-05-11 12:24
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0091_auto_20180513_1641'),
]
operations = [
migrations.AddField(
model_name='item',
name='original_price',
field=models.DecimalField(blank=True, decimal_places=2,
help_text='If set, this will be displayed next to the current price to show '
'that the current price is a discounted one. This is just a cosmetic '
'setting and will not actually impact pricing.',
max_digits=7, null=True, verbose_name='Original price'),
),
]

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-05-28 14:32
from __future__ import unicode_literals
from django.db import migrations, models
from django.utils.crypto import get_random_string
def set_pids(apps, schema_editor):
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
taken = set()
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
for op in OrderPosition.objects.iterator():
while True:
code = get_random_string(length=10, allowed_chars=charset)
if code not in taken:
op.pseudonymization_id = code
taken.add(code)
break
op.save(update_fields=['pseudonymization_id'])
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0092_auto_20180511_1224'),
]
operations = [
migrations.AddField(
model_name='orderposition',
name='pseudonymization_id',
field=models.CharField(db_index=True, max_length=16, null=True, unique=True),
),
migrations.RunPython(
set_pids,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name='orderposition',
name='pseudonymization_id',
field=models.CharField(db_index=True, default='', max_length=16, unique=True),
preserve_default=False,
),
]

View File

@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-06-04 11:19
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0093_auto_20180528_1432'),
('pretixapi', '0001_initial')
]
operations = [
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-06-04 11:29
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0094_auto_20180604_1119'),
]
operations = [
migrations.AddField(
model_name='logentry',
name='oauth_application',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
to='pretixapi.OAuthApplication'),
),
]

View File

@@ -25,13 +25,13 @@ class UserManager(BaseUserManager):
model documentation to see what's so special about our user model.
"""
def create_user(self, email: str, password: str=None, **kwargs):
def create_user(self, email: str, password: str = None, **kwargs):
user = self.model(email=email, **kwargs)
user.set_password(password)
user.save()
return user
def create_superuser(self, email: str, password: str=None): # NOQA
def create_superuser(self, email: str, password: str = None): # NOQA
# Not used in the software but required by Django
if password is None:
raise Exception("You must provide a password")
@@ -93,7 +93,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
verbose_name=_('Timezone'))
require_2fa = models.BooleanField(
default=False,
verbose_name=_('Two-factor authentification is required to log in')
verbose_name=_('Two-factor authentication is required to log in')
)
notifications_send = models.BooleanField(
default=True,
@@ -248,6 +248,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
teams = self._get_teams_for_event(organizer, event)
if teams:
self._teamcache['e{}'.format(event.pk)] = teams
if isinstance(perm_name, (tuple, list)):
return any([any(team.has_permission(p) for team in teams) for p in perm_name])
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True
return False
@@ -266,6 +268,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return True
teams = self._get_teams_for_organizer(organizer)
if teams:
if isinstance(perm_name, (tuple, list)):
return any([any(team.has_permission(p) for team in teams) for p in perm_name])
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True
return False

View File

@@ -36,7 +36,7 @@ def cached_file_delete(sender, instance, **kwargs):
class LoggingMixin:
def log_action(self, action, data=None, user=None, api_token=None, save=True):
def log_action(self, action, data=None, user=None, api_token=None, auth=None, save=True):
"""
Create a LogEntry object that is related to this object.
See the LogEntry documentation for details.
@@ -47,6 +47,8 @@ class LoggingMixin:
"""
from .log import LogEntry
from .event import Event
from pretix.api.models import OAuthAccessToken, OAuthApplication
from .organizer import TeamAPIToken
from ..notifications import get_all_notification_types
from ..services.notifications import notify
@@ -57,7 +59,18 @@ class LoggingMixin:
event = self.event
if user and not user.is_authenticated:
user = None
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, api_token=api_token)
kwargs = {}
if isinstance(auth, OAuthAccessToken):
kwargs['oauth_application'] = auth.application
elif isinstance(auth, OAuthApplication):
kwargs['oauth_application'] = auth
elif isinstance(auth, TeamAPIToken):
kwargs['api_token'] = auth
elif isinstance(api_token, TeamAPIToken):
kwargs['api_token'] = api_token
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, **kwargs)
if data:
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
if save:
@@ -83,4 +96,4 @@ class LoggedModel(models.Model, LoggingMixin):
return LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
).select_related('user', 'event')
).select_related('user', 'event', 'oauth_application', 'api_token')

View File

@@ -168,3 +168,11 @@ class Checkin(models.Model):
return "<Checkin: pos {} on list '{}' at {}>".format(
self.position, self.list, self.datetime
)
def save(self, **kwargs):
self.position.order.touch()
super().save(**kwargs)
def delete(self, **kwargs):
self.position.order.touch()
super().delete(**kwargs)

View File

@@ -2,6 +2,7 @@ import string
import uuid
from collections import OrderedDict
from datetime import datetime, time
from operator import attrgetter
import pytz
from django.conf import settings
@@ -495,6 +496,22 @@ class Event(EventMixin, LoggedModel):
renderers[pp.identifier] = pp
return renderers
def get_data_shredders(self) -> dict:
"""
Returns a dictionary of initialized data shredders mapped by their identifiers.
"""
from ..signals import register_data_shredders
responses = register_data_shredders.send(self)
renderers = {}
for receiver, response in responses:
if not isinstance(response, list):
response = [response]
for p in response:
pp = p(self)
renderers[pp.identifier] = pp
return renderers
@property
def invoice_renderer(self):
"""
@@ -519,12 +536,63 @@ class Event(EventMixin, LoggedModel):
)
).order_by('date_from', 'name')
@property
def subevent_list_subevents(self):
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
orderfields = {
'date_ascending': ('date_from', 'name'),
'date_descending': ('-date_from', 'name'),
'name_ascending': ('name', 'date_from'),
'name_descending': ('-name', 'date_from'),
}[ordering]
subevs = self.subevents.filter(
Q(active=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(date_to__gte=now())
)
) # order_by doesn't make sense with I18nField
return sorted(subevs, key=attrgetter(*orderfields))
@property
def meta_data(self):
data = {p.name: p.default for p in self.organizer.meta_properties.all()}
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return data
@property
def has_payment_provider(self):
result = False
for provider in self.get_payment_providers().values():
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice'):
result = True
break
return result
@property
def has_paid_things(self):
from .items import Item, ItemVariation
return Item.objects.filter(event=self, default_price__gt=0).exists()\
or ItemVariation.objects.filter(item__event=self, default_price__gt=0).exists()
@cached_property
def live_issues(self):
from pretix.base.signals import event_live_issues
issues = []
if self.has_paid_things and not self.has_payment_provider:
issues.append(_('You have configured at least one paid product but have not enabled any payment methods.'))
if not self.quotas.exists():
issues.append(_('You need to configure at least one quota to sell anything.'))
responses = event_live_issues.send(self)
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
if response:
issues.append(response)
return issues
def get_users_with_any_permission(self):
"""
Returns a queryset of users who have any permission to this event.
@@ -556,9 +624,78 @@ class Event(EventMixin, LoggedModel):
return User.objects.annotate(twp=Exists(team_with_perm)).filter(twp=True)
def clean_live(self):
for issue in self.live_issues:
if issue:
raise ValidationError(issue)
def allow_delete(self):
return not self.orders.exists() and not self.invoices.exists()
def delete_sub_objects(self):
self.items.all().delete()
self.subevents.all().delete()
def set_active_plugins(self, modules, allow_restricted=False):
from pretix.base.plugins import get_all_plugins
plugins_active = self.get_plugins()
plugins_available = {
p.module: p for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and not allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=False):
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
@staticmethod
def clean_has_subevents(event, has_subevents):
if event is not None and event.has_subevents is not None:
if event.has_subevents != has_subevents:
raise ValidationError(_('Once created an event cannot change between an series and a single event.'))
@staticmethod
def clean_slug(organizer, event, slug):
if event is not None and event.slug is not None:
if event.slug != slug:
raise ValidationError(_('The event slug cannot be changed.'))
else:
if Event.objects.filter(slug=slug, organizer=organizer).exists():
raise ValidationError(_('This slug has already been used for a different event.'))
@staticmethod
def clean_dates(date_from, date_to):
if date_from is not None and date_to is not None:
if date_from > date_to:
raise ValidationError(_('The event cannot end before it starts.'))
@staticmethod
def clean_presale(presale_start, presale_end):
if presale_start is not None and presale_end is not None:
if presale_start > presale_end:
raise ValidationError(_('The event\'s presale cannot end before it starts.'))
class SubEvent(EventMixin, LoggedModel):
"""
@@ -660,7 +797,7 @@ class SubEvent(EventMixin, LoggedModel):
return self.event.currency
def allow_delete(self):
return self.event.subevents.count() > 1
return not self.orderposition_set.exists()
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)

View File

@@ -83,6 +83,7 @@ class Invoice(models.Model):
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
foreign_currency_rate = models.DecimalField(decimal_places=4, max_digits=10, null=True, blank=True)
foreign_currency_rate_date = models.DateField(null=True, blank=True)
shredded = models.BooleanField(default=False)
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
internal_reference = models.TextField(blank=True)

View File

@@ -43,6 +43,11 @@ class ItemCategory(LoggedModel):
max_length=255,
verbose_name=_("Category name"),
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
help_text=_("If you set this, this will be used instead of the public name in the backend."),
blank=True, null=True, max_length=255
)
description = I18nTextField(
blank=True, verbose_name=_("Category description")
)
@@ -63,9 +68,10 @@ class ItemCategory(LoggedModel):
ordering = ('position', 'id')
def __str__(self):
name = self.internal_name or self.name
if self.is_addon:
return _('{category} (Add-On products)').format(category=str(self.name))
return str(self.name)
return _('{category} (Add-On products)').format(category=str(name))
return str(name)
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -185,6 +191,8 @@ class Item(LoggedModel):
:type min_per_order: int
:param checkin_attention: Requires special attention at check-in
:type checkin_attention: bool
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal
"""
event = models.ForeignKey(
@@ -205,6 +213,11 @@ class Item(LoggedModel):
max_length=255,
verbose_name=_("Item name"),
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
help_text=_("If you set this, this will be used instead of the public name in the backend."),
blank=True, null=True, max_length=255
)
active = models.BooleanField(
default=True,
verbose_name=_("Active"),
@@ -300,8 +313,15 @@ class Item(LoggedModel):
'attention. You can use this for example for student tickets to indicate to the person at '
'check-in that the student ID card still needs to be checked.')
)
original_price = models.DecimalField(
verbose_name=_('Original price'),
blank=True, null=True,
max_digits=7, decimal_places=2,
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
# !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/views/item.py if applicable.
# pretix/control/forms/item.py if applicable.
class Meta:
verbose_name = _("Product")
@@ -309,7 +329,7 @@ class Item(LoggedModel):
ordering = ("category__position", "category", "position")
def __str__(self):
return str(self.name)
return str(self.internal_name or self.name)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
@@ -465,7 +485,7 @@ class ItemVariation(models.Model):
return self.default_price if self.default_price is not None else self.item.default_price
def tax(self, price=None):
price = price or self.price
price = price if price is not None else self.price
if not self.item.tax_rule:
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
return self.item.tax_rule.tax(price)
@@ -938,6 +958,7 @@ class Quota(LoggedModel):
class Meta:
verbose_name = _("Quota")
verbose_name_plural = _("Quotas")
ordering = ('name',)
def __str__(self):
return self.name
@@ -990,7 +1011,7 @@ class Quota(LoggedModel):
self.cached_availability_number = res[1]
self.cached_availability_time = now_dt
if self.size is None:
self.cached_availability_paid_orders = self.count_pending_orders()
self.cached_availability_paid_orders = self.count_paid_orders()
self.save(
update_fields=[
'cached_availability_state', 'cached_availability_number', 'cached_availability_time',
@@ -1067,9 +1088,10 @@ class Quota(LoggedModel):
return CartPosition.objects.filter(
Q(event=self.event) & Q(subevent=self.subevent) &
Q(expires__gte=now_dt) &
~Q(
Q(voucher__isnull=False) & Q(voucher__block_quota=True)
& Q(Q(voucher__valid_until__isnull=True) | Q(voucher__valid_until__gte=now_dt))
Q(
Q(voucher__isnull=True)
| Q(voucher__block_quota=False)
| Q(voucher__valid_until__lt=now_dt)
) &
self._position_lookup
).count()

View File

@@ -41,10 +41,12 @@ class LogEntry(models.Model):
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT)
oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL)
action_type = models.CharField(max_length=255)
data = models.TextField(default='{}')
visible = models.BooleanField(default=True)
shredded = models.BooleanField(default=False)
objects = VisibleOnlyManager()
all = models.Manager()

View File

@@ -2,7 +2,7 @@ import copy
import json
import os
import string
from datetime import datetime, time
from datetime import datetime, time, timedelta
from decimal import Decimal
from typing import Any, Dict, List, Union
@@ -180,6 +180,9 @@ class Order(LoggedModel):
verbose_name=_("Meta information"),
null=True, blank=True
)
last_modified = models.DateTimeField(
auto_now=True, db_index=True
)
class Meta:
verbose_name = _("Order")
@@ -191,7 +194,10 @@ class Order(LoggedModel):
@cached_property
def meta_info_data(self):
return json.loads(self.meta_info)
try:
return json.loads(self.meta_info)
except TypeError:
return None
@property
def full_code(self):
@@ -205,12 +211,48 @@ class Order(LoggedModel):
def changable(self):
return self.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
def save(self, *args, **kwargs):
def save(self, **kwargs):
if 'update_fields' in kwargs and 'last_modified' not in kwargs['update_fields']:
kwargs['update_fields'] = list(kwargs['update_fields']) + ['last_modified']
if not self.code:
self.assign_code()
if not self.datetime:
self.datetime = now()
super().save(*args, **kwargs)
if not self.expires:
self.set_expires()
super().save(**kwargs)
def touch(self):
self.save(update_fields=['last_modified'])
def set_expires(self, now_dt=None, subevents=None):
now_dt = now_dt or now()
tz = pytz.timezone(self.event.settings.timezone)
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int))
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if self.event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
exp_by_date += timedelta(days=2)
elif exp_by_date.weekday() == 6:
exp_by_date += timedelta(days=1)
self.expires = exp_by_date
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if self.event.has_subevents and subevents:
term_last = min([
term_last.datetime(se).date()
for se in subevents
])
else:
term_last = term_last.datetime(self.event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
if term_last < self.expires:
self.expires = term_last
@cached_property
def tax_total(self):
@@ -544,8 +586,15 @@ class QuestionAnswer(models.Model):
def save(self, *args, **kwargs):
if self.orderposition and self.cartposition:
raise ValueError('QuestionAnswer cannot be linked to an order and a cart position at the same time.')
if self.orderposition:
self.orderposition.order.touch()
super().save(*args, **kwargs)
def delete(self, **kwargs):
if self.orderposition:
self.orderposition.order.touch()
super().delete(**kwargs)
class AbstractPosition(models.Model):
"""
@@ -671,11 +720,13 @@ class OrderFee(models.Model):
FEE_TYPE_SHIPPING = "shipping"
FEE_TYPE_SERVICE = "service"
FEE_TYPE_OTHER = "other"
FEE_TYPE_GIFTCARD = "giftcard"
FEE_TYPES = (
(FEE_TYPE_PAYMENT, _("Payment fee")),
(FEE_TYPE_SHIPPING, _("Shipping fee")),
(FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_OTHER, _("Other fees")),
(FEE_TYPE_GIFTCARD, _("Gift card")),
)
value = models.DecimalField(
@@ -746,8 +797,13 @@ class OrderFee(models.Model):
def save(self, *args, **kwargs):
if self.tax_rate is None:
self._calculate_tax()
self.order.touch()
return super().save(*args, **kwargs)
def delete(self, **kwargs):
self.order.touch()
super().delete(**kwargs)
class OrderPosition(AbstractPosition):
"""
@@ -779,6 +835,11 @@ class OrderPosition(AbstractPosition):
verbose_name=_('Tax value')
)
secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True)
pseudonymization_id = models.CharField(
max_length=16,
unique=True,
db_index=True
)
class Meta:
verbose_name = _("Order position")
@@ -856,11 +917,28 @@ class OrderPosition(AbstractPosition):
def save(self, *args, **kwargs):
if self.tax_rate is None:
self._calculate_tax()
self.order.touch()
if self.pk is None:
while OrderPosition.objects.filter(secret=self.secret).exists():
self.secret = generate_position_secret()
if not self.pseudonymization_id:
self.assign_pseudonymization_id()
return super().save(*args, **kwargs)
def assign_pseudonymization_id(self):
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
# handwriting (2/Z, 4/A, 5/S, 6/G). This allows for better detection e.g. in incoming wire transfers that
# might include OCR'd handwritten text
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True:
code = get_random_string(length=10, allowed_chars=charset)
if not OrderPosition.objects.filter(pseudonymization_id=code).exists():
self.pseudonymization_id = code
return
class CartPosition(AbstractPosition):
"""
@@ -940,6 +1018,11 @@ class InvoiceAddress(models.Model):
blank=True
)
def save(self, **kwargs):
if self.order:
self.order.touch()
super().save(**kwargs)
def cachedticket_name(instance, filename: str) -> str:
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)

View File

@@ -137,7 +137,9 @@ class Team(LoggedModel):
)
can_change_organizer_settings = models.BooleanField(
default=False,
verbose_name=_("Can change organizer settings")
verbose_name=_("Can change organizer settings"),
help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy '
'reports, so be careful who you add to this team!')
)
can_change_event_settings = models.BooleanField(
@@ -278,6 +280,8 @@ class TeamAPIToken(models.Model):
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
event in self.team.limit_events.all()
)
if isinstance(perm_name, (tuple, list)):
return has_event_access and any(self.team.has_permission(p) for p in perm_name)
return has_event_access and (not perm_name or self.team.has_permission(perm_name))
def has_organizer_permission(self, organizer, perm_name=None, request=None):
@@ -290,6 +294,8 @@ class TeamAPIToken(models.Model):
:param request: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
if isinstance(perm_name, (tuple, list)):
return organizer == self.team.organizer and any(self.team.has_permission(p) for p in perm_name)
return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name))
def get_events_with_any_permission(self):

View File

@@ -160,18 +160,19 @@ class TaxRule(LoggedModel):
def get_matching_rule(self, invoice_address):
rules = json.loads(self.custom_rules)
for r in rules:
if r['country'] == 'EU' and str(invoice_address.country) not in EU_COUNTRIES:
continue
if r['country'] not in ('ZZ', 'EU') and r['country'] != str(invoice_address.country):
continue
if r['address_type'] == 'individual' and invoice_address.is_business:
continue
if r['address_type'] in ('business', 'business_vat_id') and not invoice_address.is_business:
continue
if r['address_type'] == 'business_vat_id' and (not invoice_address.vat_id or not invoice_address.vat_id_validated):
continue
return r
if invoice_address:
for r in rules:
if r['country'] == 'EU' and str(invoice_address.country) not in EU_COUNTRIES:
continue
if r['country'] not in ('ZZ', 'EU') and r['country'] != str(invoice_address.country):
continue
if r['address_type'] == 'individual' and invoice_address.is_business:
continue
if r['address_type'] in ('business', 'business_vat_id') and not invoice_address.is_business:
continue
if r['address_type'] == 'business_vat_id' and (not invoice_address.vat_id or not invoice_address.vat_id_validated):
continue
return r
return {'action': 'vat'}
def is_reverse_charge(self, invoice_address):

View File

@@ -73,15 +73,11 @@ class WaitingListEntry(LoggedModel):
return '%s waits for %s' % (str(self.email), str(self.item))
def clean(self):
if WaitingListEntry.objects.filter(
item=self.item, variation=self.variation, email=self.email, voucher__isnull=True
).exclude(pk=self.pk).exists():
raise ValidationError(_('You are already on this waiting list! We will notify '
'you as soon as we have a ticket available for you.'))
if not self.variation and self.item.has_variations:
raise ValidationError(_('Please select a specific variation of this product.'))
WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk)
WaitingListEntry.clean_itemvar(self.event, self.item, self.variation)
WaitingListEntry.clean_subevent(self.event, self.subevent)
def send_voucher(self, quota_cache=None, user=None):
def send_voucher(self, quota_cache=None, user=None, auth=None):
availability = (
self.variation.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
if self.variation
@@ -91,6 +87,8 @@ class WaitingListEntry(LoggedModel):
raise WaitingListException(_('This product is currently not available.'))
if self.voucher:
raise WaitingListException(_('A voucher has already been sent to this person.'))
if '@' not in self.email:
raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
with transaction.atomic():
v = Voucher.objects.create(
@@ -116,8 +114,8 @@ class WaitingListEntry(LoggedModel):
'email': self.email,
'waitinglistentry': self.pk,
'subevent': self.subevent.pk if self.subevent else None,
}, user=user)
self.log_action('pretix.waitinglist.voucher', user=user)
}, user=user, auth=auth)
self.log_action('pretix.waitinglist.voucher', user=user, auth=auth)
self.voucher = v
self.save()
@@ -136,3 +134,29 @@ class WaitingListEntry(LoggedModel):
self.event,
locale=self.locale
)
@staticmethod
def clean_itemvar(event, item, variation):
if event != item.event:
raise ValidationError(_('The selected item does not belong to this event.'))
if item.has_variations and (not variation or variation.item != item):
raise ValidationError(_('Please select a specific variation of this product.'))
@staticmethod
def clean_subevent(event, subevent):
if event.has_subevents:
if not subevent:
raise ValidationError(_('Subevent cannot be null for event series.'))
if event != subevent.event:
raise ValidationError(_('The subevent does not belong to this event.'))
else:
if subevent:
raise ValidationError(_('The subevent does not belong to this event.'))
@staticmethod
def clean_duplicate(email, item, variation, subevent, pk):
if WaitingListEntry.objects.filter(
item=item, variation=variation, email=email, voucher__isnull=True, subevent=subevent
).exclude(pk=pk).exists():
raise ValidationError(_('You are already on this waiting list! We will notify '
'you as soon as we have a ticket available for you.'))

View File

@@ -174,6 +174,7 @@ class ParametrizedOrderNotificationType(NotificationType):
title=self._title.format(order=order, event=logentry.event),
url=order_url
)
n.add_attribute(_('Event'), order.event.name)
n.add_attribute(_('Order code'), order.code)
n.add_attribute(_('Order total'), money_filter(order.total, logentry.event.currency))
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))

View File

@@ -566,6 +566,19 @@ class BasePaymentProvider:
messages.success(request, _('The order has been marked as refunded. Please transfer the money '
'back to the buyer manually.'))
def shred_payment_info(self, order: Order):
"""
When personal data is removed from an event, this method is called to scrub payment-related data
from an order. By default, it removes all info from the ``payment_info`` attribute. You can override
this behavior if you want to retain attributes that are not personal data on their own, i.e. a
reference to a transaction in an external system. You can also override this to scrub more data, e.g.
data from external sources that is saved in LogEntry objects or other places.
:param order: An order
"""
order.payment_info = None
order.save(update_fields=['payment_info'])
class PaymentException(Exception):
pass
@@ -645,6 +658,39 @@ class FreeOrderProvider(BasePaymentProvider):
return False
class BoxOfficeProvider(BasePaymentProvider):
is_implicit = True
is_enabled = True
identifier = "boxoffice"
verbose_name = _("Box office")
def payment_perform(self, request: HttpRequest, order: Order):
from pretix.base.services.orders import mark_order_paid
try:
mark_order_paid(order, 'boxoffice', send_mail=False)
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
@property
def settings_form_fields(self) -> dict:
return {}
def order_control_refund_render(self, order: Order) -> str:
return ''
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
from pretix.base.services.orders import mark_order_refunded
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded.'))
def is_allowed(self, request: HttpRequest) -> bool:
return False
def order_change_allowed(self, order: Order) -> bool:
return False
@receiver(register_payment_providers, dispatch_uid="payment_free")
def register_payment_provider(sender, **kwargs):
return FreeOrderProvider
return [FreeOrderProvider, BoxOfficeProvider]

307
src/pretix/base/pdf.py Normal file
View File

@@ -0,0 +1,307 @@
import copy
import logging
import re
import uuid
from collections import OrderedDict
from io import BytesIO
import bleach
from django.contrib.staticfiles import finders
from django.utils.formats import date_format
from django.utils.translation import ugettext_lazy as _
from PyPDF2 import PdfFileReader
from pytz import timezone
from reportlab.graphics import renderPDF
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics.shapes import Drawing
from reportlab.lib.colors import Color
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import getAscentDescent
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from pretix.base.models import Order, OrderPosition
from pretix.base.signals import layout_text_variables
from pretix.base.templatetags.money import money_filter
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
DEFAULT_VARIABLES = OrderedDict((
("secret", {
"label": _("Ticket code (barcode content)"),
"editor_sample": "tdmruoekvkpbv1o2mv8xccvqcikvr58u",
"evaluate": lambda orderposition, order, event: orderposition.secret
}),
("order", {
"label": _("Order code"),
"editor_sample": "A1B2C",
"evaluate": lambda orderposition, order, event: orderposition.order.code
}),
("item", {
"label": _("Product name"),
"editor_sample": _("Sample product"),
"evaluate": lambda orderposition, order, event: str(orderposition.item.name)
}),
("variation", {
"label": _("Variation name"),
"editor_sample": _("Sample variation"),
"evaluate": lambda op, order, event: str(op.variation) if op.variation else ''
}),
("item_description", {
"label": _("Product description"),
"editor_sample": _("Sample product description"),
"evaluate": lambda orderposition, order, event: str(orderposition.item.description)
}),
("itemvar", {
"label": _("Product name and variation"),
"editor_sample": _("Sample product sample variation"),
"evaluate": lambda orderposition, order, event: (
'{} - {}'.format(orderposition.item.name, orderposition.variation)
if orderposition.variation else str(orderposition.item.name)
)
}),
("item_category", {
"label": _("Product category"),
"editor_sample": _("Ticket category"),
"evaluate": lambda orderposition, order, event: (
str(orderposition.item.category.name) if orderposition.item.category else ""
)
}),
("price", {
"label": _("Price"),
"editor_sample": _("123.45 EUR"),
"evaluate": lambda op, order, event: money_filter(op.price, event.currency)
}),
("attendee_name", {
"label": _("Attendee name"),
"editor_sample": _("John Doe"),
"evaluate": lambda op, order, ev: op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')
}),
("event_name", {
"label": _("Event name"),
"editor_sample": _("Sample event name"),
"evaluate": lambda op, order, ev: str(ev.name)
}),
("event_date", {
"label": _("Event date"),
"editor_sample": _("May 31st, 2017"),
"evaluate": lambda op, order, ev: ev.get_date_from_display(show_times=False)
}),
("event_date_range", {
"label": _("Event date range"),
"editor_sample": _("May 31st June 4th, 2017"),
"evaluate": lambda op, order, ev: ev.get_date_range_display()
}),
("event_begin", {
"label": _("Event begin date and time"),
"editor_sample": _("2017-05-31 20:00"),
"evaluate": lambda op, order, ev: ev.get_date_from_display(show_times=True)
}),
("event_begin_time", {
"label": _("Event begin time"),
"editor_sample": _("20:00"),
"evaluate": lambda op, order, ev: ev.get_time_from_display()
}),
("event_end", {
"label": _("Event end date and time"),
"editor_sample": _("2017-05-31 22:00"),
"evaluate": lambda op, order, ev: date_format(
ev.date_to.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT"
) if ev.date_to else ""
}),
("event_end_time", {
"label": _("Event end time"),
"editor_sample": _("22:00"),
"evaluate": lambda op, order, ev: date_format(
ev.date_to.astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT"
) if ev.date_to else ""
}),
("event_admission", {
"label": _("Event admission date and time"),
"editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format(
ev.date_admission.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT"
) if ev.date_admission else ""
}),
("event_admission_time", {
"label": _("Event admission time"),
"editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format(
ev.date_admission.astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT"
) if ev.date_admission else ""
}),
("event_location", {
"label": _("Event location"),
"editor_sample": _("Random City"),
"evaluate": lambda op, order, ev: str(ev.location).replace("\n", "<br/>\n")
}),
("invoice_name", {
"label": _("Invoice address: name"),
"editor_sample": _("John Doe"),
"evaluate": lambda op, order, ev: order.invoice_address.name if getattr(order, 'invoice_address', None) else ''
}),
("invoice_company", {
"label": _("Invoice address: company"),
"editor_sample": _("Sample company"),
"evaluate": lambda op, order, ev: order.invoice_address.company if getattr(order, 'invoice_address', None) else ''
}),
("addons", {
"label": _("List of Add-Ons"),
"editor_sample": _("Addon 1\nAddon 2"),
"evaluate": lambda op, order, ev: "<br/>".join([
'{} - {}'.format(p.item, p.variation) if p.variation else str(p.item)
for p in op.addons.select_related('item', 'variation')
])
}),
("organizer", {
"label": _("Organizer name"),
"editor_sample": _("Event organizer company"),
"evaluate": lambda op, order, ev: str(order.event.organizer.name)
}),
("organizer_info_text", {
"label": _("Organizer info text"),
"editor_sample": _("Event organizer info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
}),
))
def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES)
for recv, res in layout_text_variables.send(sender=event):
v.update(res)
return v
class Renderer:
def __init__(self, event, layout, background_file):
self.layout = layout
self.background_file = background_file
self.variables = get_variables(event)
if self.background_file:
self.bg_pdf = PdfFileReader(BytesIO(self.background_file.read()))
else:
self.bg_pdf = None
@classmethod
def _register_fonts(cls):
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
for family, styles in get_fonts().items():
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
content = o.get('content', 'secret')
if content == 'secret':
content = op.secret
elif content == 'pseudonymization_id':
content = op.pseudonymization_id
reqs = float(o['size']) * mm
qrw = QrCodeWidget(content, barLevel='H', barHeight=reqs, barWidth=reqs)
d = Drawing(reqs, reqs)
d.add(qrw)
qr_x = float(o['left']) * mm
qr_y = float(o['bottom']) * mm
renderPDF.draw(d, canvas, qr_x, qr_y)
def _get_ev(self, op, order):
return op.subevent or order.event
def _get_text_content(self, op: OrderPosition, order: Order, o: dict):
ev = self._get_ev(op, order)
if not o['content']:
return '(error)'
if o['content'] == 'other':
return o['text'].replace("\n", "<br/>\n")
elif o['content'].startswith('meta:'):
return ev.meta_data.get(o['content'][5:]) or ''
elif o['content'] in self.variables:
try:
return self.variables[o['content']]['evaluate'](op, order, ev)
except:
logger.exception('Failed to process variable.')
return '(error)'
return ''
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
font = o['fontfamily']
if o['bold']:
font += ' B'
if o['italic']:
font += ' I'
align_map = {
'left': TA_LEFT,
'center': TA_CENTER,
'right': TA_RIGHT
}
style = ParagraphStyle(
name=uuid.uuid4().hex,
fontName=font,
fontSize=float(o['fontsize']),
leading=float(o['fontsize']),
autoLeading="max",
textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
alignment=align_map[o['align']]
)
text = re.sub(
"<br[^>]*>", "<br/>",
bleach.clean(
self._get_text_content(op, order, o) or "",
tags=["br"], attributes={}, styles=[], strip=True
)
)
p = Paragraph(text, style=style)
p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
ad = getAscentDescent(font, float(o['fontsize']))
p.drawOn(canvas, float(o['left']) * mm, float(o['bottom']) * mm - ad[1])
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition):
for o in self.layout:
if o['type'] == "barcodearea":
self._draw_barcodearea(canvas, op, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
canvas.showPage()
def render_background(self, buffer, title=_('Ticket')):
from PyPDF2 import PdfFileWriter, PdfFileReader
buffer.seek(0)
new_pdf = PdfFileReader(buffer)
output = PdfFileWriter()
for page in new_pdf.pages:
bg_page = copy.copy(self.bg_pdf.getPage(0))
bg_page.mergePage(page)
output.addPage(bg_page)
output.addMetadata({
'/Title': str(title),
'/Creator': 'pretix',
})
outbuffer = BytesIO()
output.write(outbuffer)
outbuffer.seek(0)
return outbuffer

View File

@@ -10,7 +10,7 @@ from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext as _
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.i18n import language
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Voucher,
)
@@ -27,8 +27,16 @@ from pretix.presale.signals import (
)
class CartError(LazyLocaleException):
pass
class CartError(Exception):
def __init__(self, *args):
msg = args[0]
msgargs = args[1] if len(args) > 1 else None
self.args = args
if msgargs:
msg = _(msg) % msgargs
else:
msg = _(msg)
super().__init__(msg)
error_messages = {

View File

@@ -0,0 +1,147 @@
from django.db import transaction
from django.db.models import Prefetch
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from pretix.base.models import (
Checkin, CheckinList, Order, OrderPosition, Question, QuestionOption,
)
class CheckInError(Exception):
def __init__(self, msg, code):
self.msg = msg
self.code = code
super().__init__(msg)
class RequiredQuestionsError(Exception):
def __init__(self, msg, code, questions):
self.msg = msg
self.code = code
self.questions = questions
super().__init__(msg)
def _save_answers(op, answers, given_answers):
for q, a in given_answers.items():
if not a:
if q in answers:
answers[q].delete()
else:
continue
if isinstance(a, QuestionOption):
if q in answers:
qa = answers[q]
qa.answer = str(a.answer)
qa.save()
qa.options.clear()
else:
qa = op.answers.create(question=q, answer=str(a.answer))
qa.options.add(a)
elif isinstance(a, list):
if q in answers:
qa = answers[q]
qa.answer = ", ".join([str(o) for o in a])
qa.save()
qa.options.clear()
else:
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
qa.options.add(*a)
else:
if q in answers:
qa = answers[q]
qa.answer = str(a)
qa.save()
else:
op.answers.create(question=q, answer=str(a))
@transaction.atomic
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True):
"""
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time.
:param op: The order position to check in
:param clist: The order position to check in
:param given_answers: A dictionary of questions mapped to validated, given answers
:param force: When set to True, this will succeed even when the position is already checked in or when required
questions are not filled out.
:param ignore_unpaid: When set to True, this will succeed even when the order is unpaid.
:param questions_supported: When set to False, questions are ignored
:param nonce: A random nonce to prevent race conditions.
:param datetime: The datetime of the checkin, defaults to now.
"""
dt = datetime or now()
# Fetch order position with related objects
op = OrderPosition.objects.select_related(
'item', 'variation', 'order', 'addon_to'
).prefetch_related(
'item__questions',
Prefetch(
'item__questions',
queryset=Question.objects.filter(ask_during_checkin=True),
to_attr='checkin_questions'
),
'answers'
).get(pk=op.pk)
answers = {a.question: a for a in op.answers.all()}
require_answers = []
for q in op.item.checkin_questions:
if q not in given_answers:
require_answers.append(q)
_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 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
)
else:
ci, created = Checkin.objects.get_or_create(position=op, list=clist, defaults={
'datetime': dt,
'nonce': nonce,
})
if created or (nonce and nonce == ci.nonce):
if created:
op.order.log_action('pretix.event.checkin', data={
'position': op.id,
'positionid': op.positionid,
'first': True,
'forced': op.order.status != Order.STATUS_PAID,
'datetime': dt,
'list': clist.pk
})
else:
if not force:
raise CheckInError(
_('This ticket has already been redeemed.'),
'already_redeemed',
)
op.order.log_action('pretix.event.checkin', data={
'position': op.id,
'positionid': op.positionid,
'first': False,
'forced': force,
'datetime': dt,
'list': clist.pk
})

View File

@@ -39,7 +39,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
payment = payment_provider.render_invoice_text(invoice.order)
if payment_provider:
payment = payment_provider.render_invoice_text(invoice.order)
else:
payment = ""
invoice.introductory_text = str(introductory).replace('\n', '<br />')
invoice.additional_text = str(additional).replace('\n', '<br />')
@@ -92,7 +95,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.lines.all().delete()
positions = list(
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'variation').annotate(
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'subevent', 'variation').annotate(
addon_c=Count('addons')
)
)
@@ -111,6 +114,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
desc = " + " + desc
if invoice.event.settings.invoice_attendee_name and p.attendee_name:
desc += "<br />" + pgettext("invoice", "Attendee: {name}").format(name=p.attendee_name)
if invoice.event.has_subevents:
desc += "<br />" + pgettext("subevent", "Date: {}").format(p.subevent)
InvoiceLine.objects.create(
position=i, invoice=invoice, description=desc,
gross_value=p.price, tax_value=p.tax_value,
@@ -169,6 +174,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation.is_cancellation = True
cancellation.date = timezone.now().date()
cancellation.payment_provider_text = ''
cancellation.file = None
cancellation.save()
cancellation = build_cancellation(cancellation)
@@ -178,6 +184,8 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
def regenerate_invoice(invoice: Invoice):
if invoice.shredded:
return invoice
if invoice.is_cancellation:
invoice = build_cancellation(invoice)
else:
@@ -212,6 +220,10 @@ def generate_invoice(order: Order, trigger_pdf=True):
@app.task(base=TransactionAwareTask)
def invoice_pdf_task(invoice: int):
i = Invoice.objects.get(pk=invoice)
if i.shredded:
return None
if i.file:
i.file.delete()
with language(i.locale):
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
i.file.save(fname, ContentFile(fcontent))

View File

@@ -13,9 +13,10 @@ from django.db import transaction
from django.db.models import F, Max, Q, Sum
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.timezone import make_aware, now
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from pretix.api.models import OAuthApplication
from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
)
@@ -31,7 +32,6 @@ from pretix.base.models.orders import (
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TaxedPrice
from pretix.base.payment import BasePaymentProvider
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.async import ProfiledTask
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
@@ -81,7 +81,7 @@ logger = logging.getLogger(__name__)
def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None,
force: bool=False, send_mail: bool=True, user: User=None, mail_text='',
count_waitinglist=True, api_token=None) -> Order:
count_waitinglist=True, auth=None) -> Order:
"""
Marks an order as paid. This sets the payment provider, info and date and returns
the order object.
@@ -124,7 +124,7 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
'date': date or now_dt,
'manual': manual,
'force': force
}, user=user, api_token=api_token)
}, user=user, auth=auth)
order_paid.send(order.event, order=order)
invoice = None
@@ -174,7 +174,7 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
return order
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, api_token=None):
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
"""
Extends the deadline of an order. If the order is already expired, the quota will be checked to
see if this is actually still possible. If ``force`` is set to ``True``, the result of this check
@@ -188,7 +188,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
order.log_action(
'pretix.event.order.expirychanged',
user=user,
api_token=api_token,
auth=auth,
data={
'expires': order.expires,
'state_change': False
@@ -204,7 +204,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
order.log_action(
'pretix.event.order.expirychanged',
user=user,
api_token=api_token,
auth=auth,
data={
'expires': order.expires,
'state_change': True
@@ -215,7 +215,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
@transaction.atomic
def mark_order_refunded(order, user=None):
def mark_order_refunded(order, user=None, api_token=None):
"""
Mark this order as refunded. This sets the payment status and returns the order object.
:param order: The order to change
@@ -229,7 +229,7 @@ def mark_order_refunded(order, user=None):
order.status = Order.STATUS_REFUNDED
order.save()
order.log_action('pretix.event.order.refunded', user=user)
order.log_action('pretix.event.order.refunded', user=user, api_token=api_token)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
@@ -238,24 +238,21 @@ def mark_order_refunded(order, user=None):
@transaction.atomic
def mark_order_expired(order, user=None, api_token=None):
def mark_order_expired(order, user=None, auth=None):
"""
Mark this order as expired. This sets the payment status and returns the order object.
:param order: The order to change
:param user: The user that performed the change
:param api_token: The API token used to performed the change
"""
if isinstance(order, int):
order = Order.objects.get(pk=order)
if isinstance(user, int):
user = User.objects.get(pk=user)
if isinstance(api_token, int):
api_token = TeamAPIToken.objects.get(pk=api_token)
with order.event.lock():
order.status = Order.STATUS_EXPIRED
order.save()
order.log_action('pretix.event.order.expired', user=user, api_token=api_token)
order.log_action('pretix.event.order.expired', user=user, auth=auth)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
@@ -264,7 +261,7 @@ def mark_order_expired(order, user=None, api_token=None):
@transaction.atomic
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None):
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None):
"""
Mark this order as canceled
:param order: The order to change
@@ -276,13 +273,15 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None):
user = User.objects.get(pk=user)
if isinstance(api_token, int):
api_token = TeamAPIToken.objects.get(pk=api_token)
if isinstance(oauth_application, int):
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
with order.event.lock():
if not order.cancel_allowed():
raise OrderError(_('You cannot cancel this order.'))
order.status = Order.STATUS_CANCELED
order.save()
order.log_action('pretix.event.order.canceled', user=user, api_token=api_token)
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
@@ -301,8 +300,8 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None):
'secret': order.secret
})
}
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
with language(order.locale):
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
@@ -448,50 +447,22 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None):
from datetime import time
fees = _get_fees(positions, payment_provider, address, meta_info, event)
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
tz = pytz.timezone(event.settings.timezone)
exp_by_date = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int))
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
exp_by_date += timedelta(days=2)
elif exp_by_date.weekday() == 6:
exp_by_date += timedelta(days=1)
expires = exp_by_date
term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if event.has_subevents:
term_last = min([
term_last.datetime(se).date()
for se in event.subevents.filter(id__in=[p.subevent_id for p in positions])
])
else:
term_last = term_last.datetime(event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
if term_last < expires:
expires = term_last
with transaction.atomic():
order = Order.objects.create(
order = Order(
status=Order.STATUS_PENDING,
event=event,
email=email,
datetime=now_dt,
expires=expires,
locale=locale,
total=total,
payment_provider=payment_provider.identifier,
meta_info=json.dumps(meta_info or {}),
)
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
order.save()
if address:
if address.order is not None:
@@ -510,6 +481,9 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
OrderPosition.transform_cart_positions(positions, order)
order.log_action('pretix.event.order.placed')
if meta_info:
for msg in meta_info.get('confirm_messages', []):
order.log_action('pretix.event.order.consent', data={'msg': msg})
order_placed.send(event, order=order)
return order
@@ -705,7 +679,7 @@ class OrderChangeManager:
'no quota is available.'),
'paid_price_change': _('Currently, paid orders can only be changed in a way that does not change the total '
'price of the order as partial payments or refunds are not yet supported.'),
'addon_to_required': _('This is an addon product, please select the base position it should be added to.'),
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
'subevent_required': _('You need to choose a subevent for the new position.'),
}
@@ -715,6 +689,7 @@ class OrderChangeManager:
CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
SplitOperation = namedtuple('SplitOperation', ('position',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
def __init__(self, order: Order, user, notify=True):
self.order = order
@@ -770,6 +745,9 @@ class OrderChangeManager:
self._quotadiff.subtract(position.quotas)
self._operations.append(self.SubeventOperation(position, subevent, price))
def regenerate_secret(self, position: OrderPosition):
self._operations.append(self.RegenerateSecretOperation(position))
def change_price(self, position: OrderPosition, price: Decimal):
price = position.item.tax(price)
@@ -976,6 +954,15 @@ class OrderChangeManager:
})
elif isinstance(op, self.SplitOperation):
split_positions.append(op.position)
elif isinstance(op, self.RegenerateSecretOperation):
op.position.secret = generate_position_secret()
op.position.save()
CachedTicket.objects.filter(order_position__order=self.order).delete()
CachedCombinedTicket.objects.filter(order=self.order).delete()
self.order.log_action('pretix.event.order.changed.secret', user=self.user, data={
'position': op.position.pk,
'positionid': op.position.positionid,
})
if split_positions:
self.split_order = self._create_split_order(split_positions)
@@ -1140,6 +1127,7 @@ class OrderChangeManager:
self._recalculate_total_and_payment_fee()
self._reissue_invoice()
self._clear_tickets_cache()
self.order.touch()
self._check_paid_to_free()
if self.notify:
@@ -1175,10 +1163,10 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None):
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None):
try:
try:
return _cancel_order(order, user, send_mail, api_token)
return _cancel_order(order, user, send_mail, api_token, oauth_application)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):

View File

@@ -0,0 +1,91 @@
import json
from datetime import timedelta
from tempfile import NamedTemporaryFile
from typing import List
from zipfile import ZipFile
from dateutil.parser import parse
from django.conf import settings
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.async import ProfiledTask
from pretix.base.shredder import ShredError
from pretix.celery_app import app
@app.task(base=ProfiledTask)
def export(event: str, shredders: List[str]) -> None:
event = Event.objects.get(id=event)
known_shredders = event.get_data_shredders()
with NamedTemporaryFile() as rawfile:
with ZipFile(rawfile, 'w') as zipfile:
ccode = get_random_string(6)
zipfile.writestr(
'CONFIRM_CODE.txt',
ccode,
)
zipfile.writestr(
'index.json',
json.dumps({
'instance': settings.SITE_URL,
'organizer': event.organizer.slug,
'event': event.slug,
'time': now().isoformat(),
'shredders': shredders,
'confirm_code': ccode
}, indent=4)
)
for s in shredders:
shredder = known_shredders.get(s)
if not shredder:
continue
it = shredder.generate_files()
if not it:
continue
for fname, ftype, content in it:
zipfile.writestr(fname, content)
rawfile.seek(0)
cf = CachedFile()
cf.date = now()
cf.filename = event.slug + '.zip'
cf.type = 'application/pdf'
cf.expires = now() + timedelta(hours=1)
cf.save()
cf.file.save(cachedfile_name(cf, cf.filename), rawfile)
return cf.pk
@app.task(base=ProfiledTask, throws=(ShredError,))
def shred(event: str, fileid: str, confirm_code: str) -> None:
event = Event.objects.get(id=event)
known_shredders = event.get_data_shredders()
try:
cf = CachedFile.objects.get(pk=fileid)
except CachedFile.DoesNotExist:
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
with ZipFile(cf.file.file, 'r') as zipfile:
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."))
for s in indexdata['shredders']:
shredder = known_shredders.get(s)
if not shredder:
continue
shredder.shred_data()
cf.file.delete(save=False)
cf.delete()

View File

@@ -11,7 +11,8 @@ from pretix.base.signals import order_fee_type_name
class DummyObject:
pass
def __str__(self):
return str(self.name)
class Dontsum:

View File

@@ -495,6 +495,10 @@ Your {event} team"""))
'default': '',
'type': LazyI18nString
},
'frontpage_subevent_ordering': {
'default': 'date_ascending',
'type': str
},
}
settings_hierarkey = Hierarkey(attribute_name='settings')

351
src/pretix/base/shredder.py Normal file
View File

@@ -0,0 +1,351 @@
import json
from datetime import timedelta
from typing import List, Tuple
from django.db import transaction
from django.db.models import Max
from django.db.models.functions import Greatest
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.api.serializers.order import (
AnswerSerializer, InvoiceAddressSerializer,
)
from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPosition,
QuestionAnswer,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders
class ShredError(LazyLocaleException):
pass
def shred_constraints(event: Event):
if event.has_subevents:
max_date = event.subevents.aggregate(
max_from=Max('date_from'),
max_to=Max('date_to'),
max_fromto=Greatest(Max('date_to'), Max('date_from'))
)
max_date = max_date['max_fromto'] or max_date['max_to'] or max_date['max_from']
if max_date > now() - timedelta(days=60):
return _('Your event needs to be over for at least 60 days to use this feature.')
else:
if (event.date_to or event.date_from) > now() - timedelta(days=60):
return _('Your event needs to be over for at least 60 days to use this feature.')
if event.live:
return _('Your ticket shop needs to be offline to use this feature.')
return None
class BaseDataShredder:
"""
This is the base class for all data shredders.
"""
def __init__(self, event: Event):
self.event = event
def __str__(self):
return self.identifier
def generate_files(self) -> List[Tuple[str, str, str]]:
"""
This method is called to export the data that is about to be shred and return a list of tuples consisting of a
filename, a file type and file content.
You can also implement this as a generator and ``yield`` those tuples instead of returning a list of them.
"""
raise NotImplementedError() # NOQA
def shred_data(self):
"""
This method is called to actually remove the data from the system. You should remove any database objects
here.
You should never delete ``LogEntry`` objects, but you might modify them to remove personal data. In this
case, set the ``LogEntry.shredded`` attribute to ``True`` to show that this is no longer original log data.
"""
raise NotImplementedError() # NOQA
@property
def tax_relevant(self):
"""
Indicates whether this removes potentially tax-relevant data.
"""
return False
@property
def verbose_name(self) -> str:
"""
A human-readable name for what this shredder removes. This should be short but self-explanatory.
Good examples include 'E-Mail addresses' or 'Invoices'.
"""
raise NotImplementedError() # NOQA
@property
def identifier(self) -> str:
"""
A short and unique identifier for this shredder.
This should only contain lowercase letters and in most
cases will be the same as your package name.
"""
raise NotImplementedError() # NOQA
@property
def description(self) -> str:
"""
A more detailed description of what this shredder does. Can contain HTML.
"""
raise NotImplementedError() # NOQA
def shred_log_fields(logentry, blacklist=None, whitelist=None):
d = logentry.parsed_data
if whitelist:
for k, v in d.items():
if k not in whitelist:
d[k] = ''
elif blacklist:
for f in blacklist:
if f in d:
d[f] = ''
logentry.data = json.dumps(d)
logentry.shredded = True
logentry.save(update_fields=['data', 'shredded'])
class EmailAddressShredder(BaseDataShredder):
verbose_name = _('E-mails')
identifier = 'order_emails'
description = _('This will remove all e-mail addresses from orders and attendees, as well as logged email '
'contents.')
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'emails-by-order.json', 'application/json', json.dumps({
o.code: o.email for o in self.event.orders.filter(email__isnull=False)
}, indent=4)
yield 'emails-by-attendee.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): op.attendee_email
for op in OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False)
}, indent=4)
@transaction.atomic
def shred_data(self):
OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
for o in self.event.orders.all():
o.email = None
d = o.meta_info_data
if d:
if 'contact_form_data' in d and 'email' in d['contact_form_data']:
del d['contact_form_data']['email']
o.meta_info = json.dumps(d)
o.save(update_fields=['meta_info', 'email'])
for le in self.event.logentry_set.filter(action_type__contains="order.email"):
shred_log_fields(le, blacklist=['recipient', 'message', 'subject'])
for le in self.event.logentry_set.filter(action_type="pretix.event.order.contact.changed"):
shred_log_fields(le, blacklist=['old_email', 'new_email'])
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified").exclude(data=""):
d = le.parsed_data
if 'data' in d:
for row in d['data']:
if 'attendee_email' in row:
row['attendee_email'] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
class WaitingListShredder(BaseDataShredder):
verbose_name = _('Waiting list')
identifier = 'waiting_list'
description = _('This will remove all email addresses from the waiting list.')
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'waiting-list.json', 'application/json', json.dumps([
WaitingListSerializer(wle).data
for wle in self.event.waitinglistentries.all()
], indent=4)
@transaction.atomic
def shred_data(self):
self.event.waitinglistentries.update(email='')
for wle in self.event.waitinglistentries.select_related('voucher').filter(voucher__isnull=False):
if '@' in wle.voucher.comment:
wle.voucher.comment = ''
wle.voucher.save(update_fields=['comment'])
for le in self.event.logentry_set.filter(action_type="pretix.voucher.added.waitinglist").exclude(data=""):
d = le.parsed_data
d['email'] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
class AttendeeNameShredder(BaseDataShredder):
verbose_name = _('Attendee names')
identifier = 'attendee_names'
description = _('This will remove all attendee names from order positions, as well as logged changes to them.')
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'attendee-names.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name
for op in OrderPosition.objects.filter(order__event=self.event, attendee_name__isnull=False)
}, indent=4)
@transaction.atomic
def shred_data(self):
OrderPosition.objects.filter(order__event=self.event, attendee_name__isnull=False).update(attendee_name=None)
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified").exclude(data=""):
d = le.parsed_data
if 'data' in d:
for i, row in enumerate(d['data']):
if 'attendee_name' in row:
d['data'][i]['attendee_name'] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
class InvoiceAddressShredder(BaseDataShredder):
verbose_name = _('Invoice addresses')
identifier = 'invoice_addresses'
tax_relevant = True
description = _('This will remove all invoice addresses from orders, as well as logged changes to them.')
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'invoice-addresses.json', 'application/json', json.dumps({
ia.order.code: InvoiceAddressSerializer(ia).data
for ia in InvoiceAddress.objects.filter(order__event=self.event)
}, indent=4)
@transaction.atomic
def shred_data(self):
InvoiceAddress.objects.filter(order__event=self.event).delete()
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified").exclude(data=""):
d = le.parsed_data
if 'invoice_data' in d and not isinstance(d['invoice_data'], bool):
for field in d['invoice_data']:
if d['invoice_data'][field]:
d['invoice_data'][field] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
class QuestionAnswerShredder(BaseDataShredder):
verbose_name = _('Question answers')
identifier = 'question_answers'
description = _('This will remove all answers to questions, as well as logged changes to them.')
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'question-answers.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): AnswerSerializer(op.answers.all(), many=True).data
for op in OrderPosition.objects.filter(order__event=self.event).prefetch_related('answers')
}, indent=4)
@transaction.atomic
def shred_data(self):
QuestionAnswer.objects.filter(orderposition__order__event=self.event).delete()
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified").exclude(data=""):
d = le.parsed_data
if 'data' in d:
for i, row in enumerate(d['data']):
for f in row:
if f not in ('attendee_name', 'attendee_email'):
d['data'][i][f] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
class InvoiceShredder(BaseDataShredder):
verbose_name = _('Invoices')
identifier = 'invoices'
tax_relevant = True
description = _('This will remove all invoice PDFs, as well as any of their text content that might contain '
'personal data from the database. Invoice numbers and totals will be conserved.')
def generate_files(self) -> List[Tuple[str, str, str]]:
for i in self.event.invoices.filter(shredded=False):
if not i.file:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
i.file.open('rb')
yield 'invoices/{}.pdf'.format(i.number), 'application/pdf', i.file.read()
i.file.close()
@transaction.atomic
def shred_data(self):
for i in self.event.invoices.filter(shredded=False):
if i.file:
i.file.delete()
i.shredded = True
i.introductory_text = ""
i.additional_text = ""
i.invoice_to = ""
i.payment_provider_text = ""
i.save()
i.lines.update(description="")
class CachedTicketShredder(BaseDataShredder):
verbose_name = _('Cached ticket files')
identifier = 'cachedtickets'
description = _('This will remove all cached ticket files. No download will be offered.')
def generate_files(self) -> List[Tuple[str, str, str]]:
pass
@transaction.atomic
def shred_data(self):
CachedTicket.objects.filter(order_position__order__event=self.event).delete()
CachedCombinedTicket.objects.filter(order__event=self.event).delete()
class PaymentInfoShredder(BaseDataShredder):
verbose_name = _('Payment information')
identifier = 'payment_info'
tax_relevant = True
description = _('This will remove payment-related information. Depending on the payment method, all data will be '
'removed or personal data only. No download will be offered.')
def generate_files(self) -> List[Tuple[str, str, str]]:
pass
@transaction.atomic
def shred_data(self):
provs = self.event.get_payment_providers()
for o in self.event.orders.all():
pprov = provs.get(o.payment_provider)
if pprov:
pprov.shred_payment_info(o)
@receiver(register_data_shredders, dispatch_uid="shredders_builtin")
def register_payment_provider(sender, **kwargs):
return [
EmailAddressShredder,
AttendeeNameShredder,
InvoiceAddressShredder,
QuestionAnswerShredder,
InvoiceShredder,
CachedTicketShredder,
PaymentInfoShredder,
WaitingListShredder
]

View File

@@ -119,7 +119,7 @@ register_payment_providers = EventPluginSignal(
)
"""
This signal is sent out to get all known payment providers. Receivers should return a
subclass of pretix.base.payment.BasePaymentProvider
subclass of pretix.base.payment.BasePaymentProvider or a list of these
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -129,7 +129,17 @@ register_invoice_renderers = EventPluginSignal(
)
"""
This signal is sent out to get all known invoice renderers. Receivers should return a
subclass of pretix.base.invoice.BaseInvoiceRenderer
subclass of pretix.base.invoice.BaseInvoiceRenderer or a list of these
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_data_shredders = EventPluginSignal(
providing_args=[]
)
"""
This signal is sent out to get all known data shredders. Receivers should return a
subclass of pretix.base.shredder.BaseDataShredder or a list of these
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -258,7 +268,7 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
"""
event_copy_data = EventPluginSignal(
providing_args=["other"]
providing_args=["other", "tax_map", "category_map", "item_map", "question_map", "variation_map"]
)
"""
This signal is sent out when a new event is created as a clone of an existing event, i.e.
@@ -275,6 +285,18 @@ mappings from object IDs in the original event to objects in the new event of th
types.
"""
item_copy_data = EventPluginSignal(
providing_args=["source", "target"]
)
"""
This signal is sent out when a new product is created as a clone of an existing product, i.e.
the settings from the older product are copied to the newer one. You can listen to this
signal to copy data or configuration stored within your plugin's models as well.
The ``sender`` keyword argument will contain the event. The ``target`` will contain the item to
copy to, the ``source`` keyword argument will contain the product to **copy from**.
"""
periodic_task = django.dispatch.Signal()
"""
This is a regular django signal (no pretix event signal) that we send out every
@@ -338,3 +360,22 @@ The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
it will be ``None``.
"""
layout_text_variables = EventPluginSignal()
"""
This signal is sent out to collect variables that can be used to display text in ticket-related PDF layouts.
Receivers are expected to return a dictionary with globally unique identifiers as keys and more
dictionaries as values that contain keys like in the following example::
return {
"product": {
"label": _("Product name"),
"editor_sample": _("Sample product"),
"evaluate": lambda orderposition, order, event: str(orderposition.item)
}
}
The evaluate member will be called with the order position, order and event as arguments. The event might
also be a subevent, if applicable.
"""

View File

@@ -58,10 +58,12 @@ ALLOWED_ATTRIBUTES = {
# Update doc/user/markdown.rst if you change this!
}
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto', 'tel']
def safelink_callback(attrs, new=False):
url = attrs.get((None, 'href'), '/')
if not is_safe_url(url) and not url.startswith('mailto:'):
if not is_safe_url(url) and not url.startswith('mailto:') and not url.startswith('tel:'):
signer = signing.Signer(salt='safe-redirect')
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
attrs[None, 'target'] = '_blank'
@@ -86,7 +88,8 @@ def markdown_compile(source):
]
),
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS,
)

View File

@@ -1,9 +1,10 @@
from django.http import FileResponse, Http404, HttpRequest, HttpResponse
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.views.generic import TemplateView
from pretix.base.models import CachedFile
from pretix.helpers.http import ChunkBasedFileResponse
class DownloadView(TemplateView):
@@ -20,7 +21,7 @@ class DownloadView(TemplateView):
if 'ajax' in request.GET:
return HttpResponse('1' if self.object.file else '0')
elif self.object.file:
resp = FileResponse(self.object.file.file, content_type=self.object.type)
resp = ChunkBasedFileResponse(self.object.file.file, content_type=self.object.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(self.object.filename)
return resp
else:

View File

@@ -7,7 +7,7 @@ from django.db.models import Prefetch
from django.utils.functional import cached_property
from pretix.base.forms.questions import (
BaseInvoiceAddressForm, BaseQuestionsForm,
BaseInvoiceAddressForm, BaseInvoiceNameForm, BaseQuestionsForm,
)
from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
@@ -144,6 +144,7 @@ class BaseQuestionsViewMixin:
class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
invoice_form_class = BaseInvoiceAddressForm
invoice_name_form_class = BaseInvoiceNameForm
only_user_visible = True
@cached_property
@@ -184,6 +185,12 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
@cached_property
def invoice_form(self):
if not self.request.event.settings.invoice_address_asked and self.request.event.settings.invoice_name_required:
return self.invoice_name_form_class(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address, validate_vat_id=False
)
return self.invoice_form_class(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,

View File

@@ -6,7 +6,9 @@ from django.core.validators import RegexValidator
from django.db.models import Q
from django.forms import formset_factory
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django.utils.translation import (
pgettext, pgettext_lazy, ugettext_lazy as _,
)
from django_countries import Countries
from django_countries.fields import LazyTypedChoiceField
from i18nfield.forms import (
@@ -236,7 +238,7 @@ class EventUpdateForm(I18nModelForm):
widgets = {
'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
}
@@ -430,8 +432,8 @@ class PaymentSettingsForm(SettingsForm):
)
payment_term_weekdays = forms.BooleanField(
label=_('Only end payment terms on weekdays'),
help_text=_("If this is activated and the payment term of any order ends on a saturday or sunday, it will be "
"moved to the next monday instead. This is required in some countries by civil law. This will "
help_text=_("If this is activated and the payment term of any order ends on a Saturday or Sunday, it will be "
"moved to the next Monday instead. This is required in some countries by civil law. This will "
"not effect the last date of payments configured above."),
required=False,
)
@@ -520,8 +522,7 @@ class InvoiceSettingsForm(SettingsForm):
label=_("Require customer name"),
required=False,
widget=forms.CheckboxInput(
attrs={'data-checkbox-dependency': '#id_invoice_address_asked',
'data-inverse-dependency': '#id_invoice_address_required'}
attrs={'data-inverse-dependency': '#id_invoice_address_required'}
),
)
invoice_address_vatid = forms.BooleanField(
@@ -855,12 +856,24 @@ class DisplaySettingsForm(SettingsForm):
label=_("Show variations of a product expanded by default"),
required=False
)
frontpage_subevent_ordering = forms.ChoiceField(
label=pgettext('subevent', 'Date ordering'),
choices=[
('date_ascending', _('Event start time')),
('date_descending', _('Event start time (descending)')),
('name_ascending', _('Name')),
('name_descending', _('Name (descending)')),
], # When adding a new ordering, remember to also define it in the event model
)
def __init__(self, *args, **kwargs):
event = kwargs['obj']
super().__init__(*args, **kwargs)
self.fields['primary_font'].choices += [
(a, a) for a in get_fonts()
]
if not event.has_subevents:
del self.fields['frontpage_subevent_ordering']
class TicketSettingsForm(SettingsForm):
@@ -985,6 +998,7 @@ class WidgetCodeForm(forms.Form):
)
compatibility_mode = forms.BooleanField(
label=_("Compatibility mode"),
required=False,
help_text=_("Our regular widget doesn't work in all website builders. If you run into trouble, try using "
"this compatibility mode.")
)
@@ -1018,7 +1032,7 @@ class EventDeleteForm(forms.Form):
}
user_pw = forms.CharField(
max_length=255,
label=_("New password"),
label=_("Your password"),
widget=forms.PasswordInput()
)
slug = forms.CharField(

View File

@@ -1,13 +1,14 @@
from django import forms
from django.apps import apps
from django.db.models import Exists, F, OuterRef, Q
from django.db.models.functions import Coalesce
from django.db.models.functions import Coalesce, ExtractWeekDay
from django.urls import reverse, reverse_lazy
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.models import (
Checkin, Event, Invoice, Item, Order, OrderPosition, Organizer, SubEvent,
Checkin, Event, Invoice, Item, Order, OrderPosition, Organizer, Question,
QuestionAnswer, SubEvent,
)
from pretix.base.signals import register_payment_providers
from pretix.control.forms.widgets import Select2
@@ -99,6 +100,7 @@ class OrderFilterForm(FilterForm):
('p', _('Paid')),
('n', _('Pending')),
('o', _('Pending (overdue)')),
('np', _('Pending or paid')),
('e', _('Expired')),
('ne', _('Pending or expired')),
('c', _('Canceled')),
@@ -128,6 +130,7 @@ class OrderFilterForm(FilterForm):
matching_positions = OrderPosition.objects.filter(
Q(order=OuterRef('pk')) & Q(
Q(attendee_name__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u)
)
).values('id')
@@ -145,6 +148,8 @@ class OrderFilterForm(FilterForm):
s = fdata.get('status')
if s == 'o':
qs = qs.filter(status=Order.STATUS_PENDING, expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'ne':
qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
@@ -175,11 +180,19 @@ class EventOrderFilterForm(OrderFilterForm):
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
question = forms.ModelChoiceField(
queryset=Question.objects.none(),
required=False,
)
answer = forms.CharField(
required=False
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['item'].queryset = self.event.items.all()
self.fields['question'].queryset = self.event.questions.all()
self.fields['provider'].choices += [(k, v.verbose_name) for k, v
in self.event.get_payment_providers().items()]
@@ -209,6 +222,31 @@ class EventOrderFilterForm(OrderFilterForm):
if fdata.get('subevent'):
qs = qs.filter(positions__subevent=fdata.get('subevent'))
if fdata.get('question') and fdata.get('answer') is not None:
q = fdata.get('question')
if q.type == Question.TYPE_FILE:
answers = QuestionAnswer.objects.filter(
orderposition__order_id=OuterRef('pk'),
question_id=q.pk,
file__isnull=False
)
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
elif q.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
answers = QuestionAnswer.objects.filter(
question_id=q.pk,
orderposition__order_id=OuterRef('pk'),
options__pk=fdata.get('answer')
)
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
else:
answers = QuestionAnswer.objects.filter(
question_id=q.pk,
orderposition__order_id=OuterRef('pk'),
answer__iexact=fdata.get('answer')
)
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
return qs
@@ -270,6 +308,20 @@ class SubEventFilterForm(FilterForm):
),
required=False
)
weekday = forms.ChoiceField(
label=_('Weekday'),
choices=(
('', _('All days')),
('2', _('Monday')),
('3', _('Tuesday')),
('4', _('Wednesday')),
('5', _('Thursday')),
('6', _('Friday')),
('7', _('Saturday')),
('1', _('Sunday')),
),
required=False
)
query = forms.CharField(
label=_('Event name'),
widget=forms.TextInput(attrs={
@@ -299,6 +351,9 @@ class SubEventFilterForm(FilterForm):
elif fdata.get('status') == 'past':
qs = qs.filter(presale_end__lte=now())
if fdata.get('weekday'):
qs = qs.annotate(wday=ExtractWeekDay('date_from')).filter(wday=fdata.get('weekday'))
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
@@ -511,6 +566,7 @@ class CheckInFilterForm(FilterForm):
u = fdata.get('user')
qs = qs.filter(
Q(order__code__istartswith=u)
| Q(secret__istartswith=u)
| Q(order__email__icontains=u)
| Q(attendee_name__icontains=u)
| Q(attendee_email__icontains=u)

View File

@@ -15,6 +15,7 @@ from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn
from pretix.base.signals import item_copy_data
from pretix.control.forms import SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2
from pretix.helpers.money import change_decimal_field
@@ -26,6 +27,7 @@ class CategoryForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'name',
'internal_name',
'description',
'is_addon'
]
@@ -89,9 +91,9 @@ class QuotaForm(I18nModelForm):
for item in items:
if len(item.variations.all()) > 0:
for v in item.variations.all():
choices.append(('{}-{}'.format(item.pk, v.pk), '{} {}'.format(item.name, v.value)))
choices.append(('{}-{}'.format(item.pk, v.pk), '{} {}'.format(item, v.value)))
else:
choices.append(('{}'.format(item.pk), item.name))
choices.append(('{}'.format(item.pk), str(item)))
self.fields['itemvars'] = forms.MultipleChoiceField(
label=_('Products'),
@@ -224,6 +226,7 @@ class ItemCreateForm(I18nModelForm):
self.instance.max_per_order = self.cleaned_data['copy_from'].max_per_order
self.instance.checkin_attention = self.cleaned_data['copy_from'].checkin_attention
self.instance.free_price = self.cleaned_data['copy_from'].free_price
self.instance.original_price = self.cleaned_data['copy_from'].original_price
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
instance = super().save(*args, **kwargs)
@@ -255,6 +258,8 @@ class ItemCreateForm(I18nModelForm):
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
return instance
def clean(self):
@@ -279,6 +284,7 @@ class ItemCreateForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'name',
'internal_name',
'category',
'admission',
'default_price',
@@ -305,6 +311,7 @@ class ItemUpdateForm(I18nModelForm):
fields = [
'category',
'name',
'internal_name',
'active',
'admission',
'description',
@@ -319,7 +326,8 @@ class ItemUpdateForm(I18nModelForm):
'allow_cancel',
'max_per_order',
'min_per_order',
'checkin_attention'
'checkin_attention',
'original_price'
]
field_classes = {
'available_from': forms.SplitDateTimeField,
@@ -340,7 +348,7 @@ class ItemVariationsFormSet(I18nFormSet):
f.fields['DELETE'].disabled = True
raise ValidationError(
message=_('The variation "%s" cannot be deleted because it has already been ordered by a user or '
'currently is in a users\'s cart. Please set the variation as "inactive" instead.'),
'currently is in a user\'s cart. Please set the variation as "inactive" instead.'),
params=(str(f.instance),)
)

View File

@@ -36,7 +36,9 @@ class ExtendForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.status == Order.STATUS_PENDING or self.instance._is_still_available(now(), count_waitinglist=False) is True:
if self.instance.status == Order.STATUS_PENDING or self.instance._is_still_available(now(),
count_waitinglist=False)\
is True:
del self.fields['quota_ignore']
def clean(self):
@@ -47,8 +49,33 @@ class ExtendForm(I18nModelForm):
return data
class ExporterForm(forms.Form):
class MarkPaidForm(forms.Form):
force = forms.BooleanField(
label=_('Overbook quota and ignore late payment'),
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
'and you having sold more tickets than you planned! The operation will also be performed '
'regardless of the settings for late payments.'),
required=False
)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance")
super().__init__(*args, **kwargs)
quota_success = (
self.instance.status == Order.STATUS_PENDING or
self.instance._is_still_available(now(), count_waitinglist=False) is True
)
term_last = self.instance.payment_term_last
term_success = (
(not term_last or term_last >= now()) and
(self.instance.status == Order.STATUS_PENDING or self.instance.event.settings.get(
'payment_term_accept_late'))
)
if quota_success and term_success:
del self.fields['force']
class ExporterForm(forms.Form):
def clean(self):
data = super().clean()
@@ -144,7 +171,7 @@ class OrderPositionAddForm(forms.Form):
choices = []
for i in order.event.items.prefetch_related('variations').all():
pname = str(i.name)
pname = str(i)
if not i.is_available():
pname += ' ({})'.format(_('inactive'))
variations = list(i.variations.all())
@@ -206,6 +233,7 @@ class OrderPositionChangeForm(forms.Form):
('subevent', 'Change event date'),
('cancel', 'Remove product'),
('split', 'Split into new order'),
('secret', 'Regenerate secret'),
)
)
@@ -243,7 +271,7 @@ class OrderPositionChangeForm(forms.Form):
choices = []
for i in instance.order.event.items.prefetch_related('variations').all():
pname = str(i.name)
pname = str(i)
if not i.is_available():
pname += ' ({})'.format(_('inactive'))
variations = list(i.variations.all())

View File

@@ -155,6 +155,10 @@ class OrganizerDisplaySettingsForm(SettingsForm):
('calendar', _('Calendar'))
)
)
organizer_link_back = forms.BooleanField(
label=_('Link back to organizer overview on all event pages'),
required=False
)
locales = forms.MultipleChoiceField(
choices=settings.LANGUAGES,
label=_("Use languages"),

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