Compare commits

...

189 Commits

Author SHA1 Message Date
Raphael Michel
2bb2f30e66 Bump version to 2.2.0 2018-11-09 16:43:59 +01:00
Raphael Michel
9a8d23f582 Banktransfer: use proper formatting for IBANs 2018-11-09 16:42:43 +01:00
Raphael Michel
f37d12e056 Merge pull request #1079 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-11-09 16:26:35 +01:00
Maarten van den Berg
334ffc0be7 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2855 of 2855 strings)

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

powered by weblate
2018-11-09 14:18:13 +00:00
Maarten van den Berg
03f0da4ee6 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (66 of 66 strings)

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

powered by weblate
2018-11-09 14:18:13 +00:00
Raphael Michel
fbbd6eebc0 Refuse to update on old MySQL 2018-11-09 15:17:58 +01:00
Raphael Michel
584ced87db Add /me API endpoint 2018-11-09 11:34:40 +01:00
Raphael Michel
901953d988 Add a Retry-After to 409 responses 2018-11-09 11:13:24 +01:00
Raphael Michel
8c34a47138 Voucher API: Bulk creation 2018-11-09 10:50:21 +01:00
Raphael Michel
0fe3db634c Voucher API: Reduce number of event locks 2018-11-09 10:46:27 +01:00
Raphael Michel
d8d838fc4f Add note on celery-requirement for webhook retrials 2018-11-09 10:45:25 +01:00
Raphael Michel
9b94a1b3b2 Add documentation on rate limits 2018-11-09 10:00:55 +01:00
Raphael Michel
479abc1a65 Add missing screenshots to docs 2018-11-08 17:00:18 +01:00
Raphael Michel
1a17ba13ca Link to documentation 2018-11-08 16:57:30 +01:00
Raphael Michel
371c42b738 Merge pull request #1077 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-11-08 16:57:18 +01:00
Raphael Michel
ed85394845 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2855 of 2855 strings)

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

powered by weblate
2018-11-08 15:56:22 +00:00
Raphael Michel
a9a684a456 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (66 of 66 strings)

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

powered by weblate
2018-11-08 15:55:48 +00:00
Raphael Michel
d7d7792a4a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (66 of 66 strings)

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

powered by weblate
2018-11-08 15:55:48 +00:00
Raphael Michel
c09587f5d3 Translated on translate.pretix.eu (German)
Currently translated at 99.8% (2848 of 2855 strings)

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

powered by weblate
2018-11-08 15:55:48 +00:00
Raphael Michel
23f719381c Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2855 of 2855 strings)

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

powered by weblate
2018-11-08 15:55:48 +00:00
Raphael Michel
d74d39d6e9 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (66 of 66 strings)

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

powered by weblate
2018-11-08 15:55:48 +00:00
Raphael Michel
5f2cf8d3ef Add documentation on webhooks 2018-11-08 16:53:25 +01:00
Raphael Michel
1843799345 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-11-08 16:40:19 +01:00
Raphael Michel
bd838b3b7c Fix #1074 -- More specific messages during asynctasks 2018-11-08 16:38:12 +01:00
Raphael Michel
c2d03f5e6b Fix #526 -- Add a webhook system (#1073)
- [x] Data model
- [x] UI
- [x] Fire hooks
- [x] Unit tests
- [x] Display logs
- [x] API to modify hooks
- [x] Documentation
- [x] More hooks!
2018-11-08 16:38:05 +01:00
Raphael Michel
74e8e73877 Stop testing against Py3.5 2018-11-08 15:44:56 +01:00
Raphael Michel
8830dc8f78 Fix tests for checkin list 2018-11-08 12:04:51 +01:00
Raphael Michel
ac877a7c0d Use 3 SQLite workers 2018-11-08 11:34:02 +01:00
Raphael Michel
0a442e712b Add company to checkin list 2018-11-08 11:01:29 +01:00
Raphael Michel
4477f8001e Adjust test for previous commit 2018-11-07 11:53:13 +01:00
Raphael Michel
152b94428f Make Item.allow_delete() more consistent 2018-11-07 11:19:23 +01:00
Raphael Michel
5390b0b191 API: Allow to sort orders by modification date 2018-11-07 10:29:41 +01:00
Raphael Michel
97de8cea08 Allow cart creation without attendee name 2018-11-06 18:16:54 +01:00
Raphael Michel
cd465c1aad Fix not passing an attendee name in the API 2018-11-06 17:42:18 +01:00
Raphael Michel
449dea41a8 Fix order list export 2018-11-06 14:57:35 +01:00
Raphael Michel
0b1a6e4745 Fix symmetry 2018-11-06 14:57:22 +01:00
Tobias Kunze
e49061e28c Don't check voucher quotas if they bypass quotas (#1070) 2018-11-06 11:01:05 +01:00
Raphael Michel
18cb29b425 Show date in event picker 2018-11-05 22:45:33 +01:00
Raphael Michel
994ff23719 Fix quick event switcher on mobile 2018-11-05 22:24:26 +01:00
Raphael Michel
15d077df6e Add explanation tooltips to invoice regeneration buttons 2018-11-05 21:46:16 +01:00
Raphael Michel
b490aa7f5d Add scheme to sample names 2018-11-05 21:35:44 +01:00
Raphael Michel
ca6b3badde Fix reference to removed field 2018-11-05 21:20:17 +01:00
Raphael Michel
1f200271af Allow rich text in question help texts 2018-11-05 18:07:15 +01:00
Raphael Michel
894a60d016 Merge pull request #1069 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-11-05 16:56:15 +01:00
Raphael Michel
4a2219134b Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2825 of 2825 strings)

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

powered by weblate
2018-11-05 15:55:43 +00:00
Raphael Michel
7d38fc5c03 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2825 of 2825 strings)

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

powered by weblate
2018-11-05 15:50:02 +00:00
Raphael Michel
ef5de187b9 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2825 of 2825 strings)

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

powered by weblate
2018-11-05 15:46:16 +00:00
Raphael Michel
a1c424266b Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-11-05 16:36:29 +01:00
Raphael Michel
557b4b7b6f Merge pull request #1060 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-11-05 16:36:02 +01:00
oocf
98be21253d Translated on translate.pretix.eu (Spanish)
Currently translated at 98.4% (2750 of 2794 strings)

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

powered by weblate
2018-11-05 15:24:40 +00:00
Maarten van den Berg
e5a04ada94 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2794 of 2794 strings)

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

powered by weblate
2018-11-05 15:24:40 +00:00
Maarten van den Berg
9b8b3090e6 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2794 of 2794 strings)

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

powered by weblate
2018-11-05 15:24:40 +00:00
Raphael Michel
e622c3948d Fix buggy migration 2018-11-05 16:24:30 +01:00
Raphael Michel
94be46ffdb Fix #978 -- Allow to split names (#1049)
- [x] attendee names
- [x] Invoice address names
- [x] Data migration
- [x] API serializers
  - [x] orderposition
  - [x] cartposition
  - [x] invoiceaddress
  - [x] checkinlistposition
- [x] position API search
- [x] invoice API search
- [x] business/individual required toggle
- [x] Split columns in CSV exports
- [x] ticket editor
- [x] shredder
- [x] ticket/invoice sample data
- [x] order search
- [x] Handle changed naming scheme
- [x] tests
- [x] make use in:
  - [x] Boabee
  - [x] Certificate download order
  - [x] Badge download order
  - [x] Ticket download order
- [x] Document new MySQL requirement
- [x] Plugins
2018-11-05 15:43:21 +01:00
Raphael Michel
7039374588 Allow to anonymize users 2018-11-05 11:11:43 +01:00
Raphael Michel
0a5347c08b Allow to delete organizers 2018-11-05 11:11:43 +01:00
Raphael Michel
87f3318431 Merge pull request #1006 from pretix/empty_vouchers
Allow to show all vouchers with empty tags
2018-11-05 10:35:22 +01:00
Tobias Kunze
2557a8e4ec Allow to show all vouchers with empty tags 2018-11-05 10:34:25 +01:00
Raphael Michel
aff7094cb0 Fix #1021 -- Bugs in SMTP test 2018-11-05 10:19:29 +01:00
Raphael Michel
5a29b4bf70 Allow to choose French and Spanish 2018-10-31 15:38:38 +01:00
Raphael Michel
e618183b49 Merge pull request #1059 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-10-31 15:38:01 +01:00
Raphael Michel
a18236b12d Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (65 of 65 strings)

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

powered by weblate
2018-10-31 14:34:07 +00:00
Raphael Michel
b5da4e89a6 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2794 of 2794 strings)

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

powered by weblate
2018-10-31 14:33:21 +00:00
Raphael Michel
1da2737427 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2794 of 2794 strings)

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

powered by weblate
2018-10-31 14:30:18 +00:00
Raphael Michel
032fdadc3c Translated on translate.pretix.eu (German)
Currently translated at 100.0% (65 of 65 strings)

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

powered by weblate
2018-10-31 14:26:13 +00:00
Raphael Michel
8ae3ff3fe6 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-10-31 15:21:52 +01:00
Raphael Michel
b8669503fa Only allow restricting payment countries if invoice address is obligatory 2018-10-31 15:21:26 +01:00
Raphael Michel
863165caaa Gracefully handle PayPal exceptions 2018-10-31 15:21:26 +01:00
Raphael Michel
b885f30789 Update from Weblate. (#1039) 2018-10-31 15:18:40 +01:00
Mattias de Hollander
461b62bd51 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (65 of 65 strings)

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

powered by weblate
2018-10-31 09:43:05 +00:00
Maarten van den Berg
23776db3b6 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2773 of 2773 strings)

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

powered by weblate
2018-10-31 09:43:05 +00:00
Arnaud Vergnet
19e91a6c7c Translated on translate.pretix.eu (French)
Currently translated at 100.0% (65 of 65 strings)

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

powered by weblate
2018-10-31 09:43:05 +00:00
Samir C. Costa
6f40325d3f Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 100.0% (65 of 65 strings)

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

powered by weblate
2018-10-31 09:43:05 +00:00
Samir C. Costa
1987bff4b1 Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 100,0% (65 of 65 strings)

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

powered by weblate
2018-10-31 09:43:05 +00:00
Maarten van den Berg
5aa0d55d47 Translated on translate.pretix.eu (Dutch)
Currently translated at 99.0% (2746 of 2773 strings)

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

powered by weblate
2018-10-31 09:43:05 +00:00
Raphael Michel
a28196e930 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2773 of 2773 strings)

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

powered by weblate
2018-10-31 09:43:05 +00:00
Raphael Michel
c55387819d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2773 of 2773 strings)

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

powered by weblate
2018-10-31 09:43:05 +00:00
Raphael Michel
c8cc527aee OrderChangeManager: Do not mark order pending when adjusting price to actual payment 2018-10-31 10:42:44 +01:00
Raphael Michel
a39b207ad5 Mark manual payment failed if nothing happened 2018-10-31 10:33:23 +01:00
Raphael Michel
ea63b50f2e Bank transfer: consider payments valid even without quota 2018-10-31 10:31:40 +01:00
Raphael Michel
b101251aa4 Log confirmed payments that could not mark the order as paid 2018-10-31 10:31:21 +01:00
Raphael Michel
c9ba72ebc5 Fix a typo 2018-10-31 10:27:14 +01:00
Raphael Michel
4a1c3088a9 Locking in OrderChangeManager caused unexpected problems 2018-10-31 10:04:53 +01:00
Raphael Michel
a480ca1142 Add reverse charge flag to invoices 2018-10-30 10:57:29 +01:00
Tobias Kunze
a928fbfafe Config files (#1057)
* Move coveragerc to setup.cfg

* Move pytest.ini to setup.cfg

Closes #1027
2018-10-30 10:12:17 +01:00
Tobias Kunze
3bf3ff1ee2 Allow empty plugin responses (#1056)
While plugin developers are supposed to return an empty dictionary, it's
conceivable that they might just put in a `return` if their field is not
needed, and pretix being generous about this would be cool.
2018-10-30 10:11:39 +01:00
Raphael Michel
9647cc6cf2 Add more favicons for browser shortcuts 2018-10-30 10:05:48 +01:00
Raphael Michel
df2d8925ed Prevent some race conditions 2018-10-29 17:27:12 +01:00
Raphael Michel
7a945daefc Fix #957 -- Integrate BezahlCode and GiroCode 2018-10-29 12:57:26 +01:00
Raphael Michel
409e77cf2f Stop pinning setuptools 2018-10-29 12:42:07 +01:00
Raphael Michel
552f99a63b Read PDF backgrounds with strict=False 2018-10-29 11:41:26 +01:00
Raphael Michel
0842311451 PDF renderer: Do not break on wrong poweredby-styles 2018-10-29 11:41:26 +01:00
Raphael Michel
4d4b498636 Resolve bug in event copy signals of pdf output and badges 2018-10-29 11:41:26 +01:00
Raphael Michel
d08cc12240 Do not break on short VAT IDs 2018-10-29 11:41:26 +01:00
Raphael Michel
237442872e Reliably delete addons when deleting cart positions 2018-10-29 11:41:25 +01:00
Raphael Michel
16983826fb Allow to store structured invoice addresses 2018-10-24 01:37:18 +02:00
Raphael Michel
e60ff6b777 Allow to store strucutred SEPA bank transfer details 2018-10-24 00:21:33 +02:00
Raphael Michel
3a0ef3760c Make logging for payment changes consistent with OrderPayment handling 2018-10-22 22:28:42 +02:00
Raphael Michel
bc0bc78219 Try to fix invoice export 2018-10-22 06:23:09 +02:00
Tobias Kunze
d3137505a1 Don't offer to check empty VAT ID (#1044) 2018-10-17 01:50:24 +02:00
Flavia Bastos
a2acd336eb Fix #970 -- invoice info consistency (#1043)
resolves: Issue #970
2018-10-17 01:50:00 +02:00
Raphael Michel
6e4750336b Fix test case for previous commit 2018-10-12 11:44:46 +02:00
Raphael Michel
ddefeeaf02 Waiting list should send things out even if waiting list is disabled 2018-10-12 10:45:33 +02:00
Raphael Michel
250e0a930d Prevent huge invoice files if a JPEG logo is used 2018-10-09 10:56:11 +02:00
Tobias Kunze
51c6d60760 Use http_date instead of cookie_date (#1042)
http_date is deprecated as of Django 2.1
2018-10-09 10:50:25 +02:00
Raphael Michel
db513b21f8 Fix Apple Pay verification for organizer domains 2018-10-09 09:22:12 +02:00
Raphael Michel
ab336678ce Allow to change slug in admin sessions 2018-10-09 09:19:36 +02:00
Raphael Michel
3eea4d6945 Show suebvent in addons view 2018-10-08 12:17:30 +02:00
Raphael Michel
d091d3fd17 Show subevent in questions form 2018-10-08 11:20:15 +02:00
Raphael Michel
fc71f484ad Fix urlconf definition 2018-10-05 10:41:35 +02:00
Raphael Michel
bd772bf900 Never fail to send an email because of missing attachments 2018-10-05 09:33:40 +02:00
Raphael Michel
14db654681 Fix Apple Pay for custom domains 2018-10-05 09:31:23 +02:00
Raphael Michel
a85b96ea89 Allow plugins to have organizer_patterns 2018-10-05 09:31:14 +02:00
Raphael Michel
c2b5e876bc Bump version to 2.2.0.dev0 2018-10-04 11:35:00 +02:00
Raphael Michel
91c02dc0b3 Bump version to 2.1.0 2018-10-04 11:33:09 +02:00
Raphael Michel
f78ec830b5 Fix pretix-stripe.js 2018-10-03 17:31:06 +02:00
Raphael Michel
9f0e508ab3 Do not require meta_noindex 2018-10-03 12:52:37 +02:00
Raphael Michel
4ca50d750b Merge pull request #1037 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-10-03 12:44:14 +02:00
Raphael Michel
07c1b1b7f3 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2773 of 2773 strings)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -11,7 +11,6 @@ fi
if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
psql -c 'create database travis_ci_test;' -U postgres
pip3 install -Ur src/requirements/postgres.txt
fi
if [ "$1" == "style" ]; then
@@ -43,7 +42,7 @@ if [ "$1" == "tests" ]; then
cd src
python manage.py check
make all compress
py.test --reruns 5 -n 2 tests
py.test --reruns 5 -n 3 tests
fi
if [ "$1" == "tests-cov" ]; then
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt

View File

@@ -1,7 +1,7 @@
language: python
sudo: false
install:
- pip install -U pip wheel setuptools==28.6.1
- pip install -U pip wheel setuptools
script:
- bash .travis.sh $JOB
cache:
@@ -18,8 +18,6 @@ matrix:
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.6
env: JOB=style
- python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
- python: 3.6
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
- python: 3.6
@@ -32,6 +30,7 @@ matrix:
env: JOB=translation-spelling
addons:
postgresql: "9.4"
mariadb: '10.3'
apt:
packages:
- enchant

View File

@@ -30,7 +30,7 @@ RUN chmod +x /usr/local/bin/pretix && \
pip3 install -U pip wheel setuptools && \
cd /pretix/src && \
rm -f pretix.cfg && \
pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
pip3 install -r requirements.txt -r requirements/mysql.txt \
-r requirements/memcached.txt -r requirements/redis.txt gunicorn && \
mkdir -p data && \
chown -R pretixuser:pretixuser /pretix /data data && \

View File

@@ -26,7 +26,7 @@ installation guides):
* `Docker`_
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `MySQL`_ or `PostgreSQL`_ database server
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
* A `redis`_ server
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
@@ -36,6 +36,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
installations except for evaluation purposes.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
On this guide
-------------
@@ -58,7 +61,7 @@ Next, we need a database and a database user. We can create these with any kind
our database's shell, e.g. for MySQL::
$ mysql -u root -p
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
mysql> FLUSH PRIVILEGES;

View File

@@ -21,6 +21,9 @@ To use pretix, you will need the following things:
.. warning:: Do not ever use SQLite in production. It will break.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
faster. Also, you need a proxying web server in front to provide SSL encryption.

View File

@@ -23,7 +23,7 @@ installation guides):
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `MySQL`_ or `PostgreSQL`_ database server
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
* A `redis`_ server
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
@@ -33,6 +33,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
installations except for evaluation purposes.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
Unix user
---------
@@ -50,7 +53,7 @@ Having the database server installed, we still need a database and a database us
of database managing tool or directly on our database's shell, e.g. for MySQL::
$ mysql -u root -p
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
mysql> FLUSH PRIVILEGES;

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

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

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

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
.. _`rest-oauth`:
OAuth support / "Connect with pretix"
=====================================
OAuth authentication / "Connect with pretix"
============================================
In addition to static tokens, pretix supports `OAuth2`_-based authentication starting with
pretix 1.16. This allows you to put a "Connect with pretix" button into your website or tool
@@ -166,6 +166,42 @@ endpoint to revoke it.
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.
Fetching the user profile
-------------------------
If you need the user's meta data, you can fetch it here:
.. http:get:: /api/v1/me
Returns the profile of the authenticated user
**Example request**:
.. sourcecode:: http
GET /api/v1/me HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Authorization: Bearer i3ytqTSRWsKp16fqjekHXa4tdM4qNC
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
email: "admin@localhost",
fullname: "John Doe",
locale: "de",
timezone: "Europe/Berlin"
}
:statuscode 200: no error
:statuscode 401: Authentication failure
.. _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

31
doc/api/ratelimit.rst Normal file
View File

@@ -0,0 +1,31 @@
.. _`rest-ratelimit`:
Rate limiting
=============
.. note:: This page only applies to the pretix Hosted service at pretix.eu. APIs of custom pretix installations do not
enforce any rate limiting by default.
All authenticated requests to pretix' API are rate limited. If you exceed the limits, you will receive a response
with HTTP status code ``429 Too Many Requests``. This response will have a ``Retry-After`` header, containing the number
of seconds you are supposed to wait until you try again. We expect that all API clients respect this. If you continue
to burst requests after a ``429`` status code, we might get in touch with you or, in extreme cases, disable your API
access.
Currently, the following rate limits apply:
.. rst-class:: rest-resource-table
===================================== =================================================================================
Authentication method Rate limit
===================================== =================================================================================
:ref:`rest-deviceauth` 360 requests per minute per device
:ref:`rest-tokenauth` 360 requests per minute per organizer account
:ref:`rest-oauth` 360 requests per minute per combination of accessed organizer and OAuth application
Session authentication *Not an officially supported authentication method for external access*
===================================== =================================================================================
If you require a higher rate limit, please get in touch at support@pretix.eu and tell us about your use case, we are
sure we can work something out.

View File

@@ -25,6 +25,7 @@ item integer ID of the item
variation integer ID of the variation (or ``null``)
price money (string) Price of this position
attendee_name string Specified attendee name for this position (or ``null``)
attendee_name_parts object of strings Composition of attendee name (i.e. first name, last name, …)
attendee_email string Specified attendee email address for this position (or ``null``)
voucher integer Internal ID of the voucher used for this position (or ``null``)
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
@@ -78,6 +79,7 @@ Cart position endpoints
"variation": null,
"price": "23.00",
"attendee_name": null,
"attendee_name_parts": {},
"attendee_email": null,
"voucher": null,
"addon_to": null,
@@ -122,6 +124,7 @@ Cart position endpoints
"variation": null,
"price": "23.00",
"attendee_name": null,
"attendee_name_parts": {},
"attendee_email": null,
"voucher": null,
"addon_to": null,
@@ -175,7 +178,7 @@ Cart position endpoints
* ``item``
* ``variation`` (optional)
* ``price``
* ``attendee_name`` (optional)
* ``attendee_name`` **or** ``attendee_name_parts`` (optional)
* ``attendee_email`` (optional)
* ``subevent`` (optional)
* ``expires`` (optional)
@@ -199,7 +202,10 @@ Cart position endpoints
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_name_parts": {
"given_name": "Peter",
"family_name": "Miller"
},
"attendee_email": null,
"answers": [
{

View File

@@ -371,6 +371,9 @@ Order position endpoints
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_name_parts": {
"full_name": "Peter",
},
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",
@@ -466,6 +469,9 @@ Order position endpoints
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_name_parts": {
"full_name": "Peter",
},
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",

View File

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

View File

@@ -21,3 +21,4 @@ Resources and endpoints
checkinlists
waitinglist
carts
webhooks

View File

@@ -46,6 +46,7 @@ invoice_address object Invoice address
for orders created before pretix 1.7, do not rely on
it).
├ name string Customer name
├ name_parts object of strings Customer name decomposition
├ street string Customer street
├ zipcode string Customer ZIP code
├ city string Customer city
@@ -137,6 +138,7 @@ item integer ID of the purch
variation integer ID of the purchased variation (or ``null``)
price money (string) Price of this position
attendee_name string Specified attendee name for this position (or ``null``)
attendee_name_parts object of strings Decomposition of attendee name (i.e. given name, family name)
attendee_email string Specified attendee email address for this position (or ``null``)
voucher integer Internal ID of the voucher used for this position (or ``null``)
tax_rate decimal (string) VAT rate applied for this position
@@ -278,6 +280,7 @@ List of all orders
"is_business": True,
"company": "Sample company",
"name": "John Doe",
"name_parts": {"full_name": "John Doe"},
"street": "Test street 12",
"zipcode": "12345",
"city": "Testington",
@@ -295,6 +298,9 @@ List of all orders
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_name_parts": {
"full_name": "Peter",
},
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",
@@ -410,6 +416,7 @@ Fetching individual orders
"company": "Sample company",
"is_business": True,
"name": "John Doe",
"name_parts": {"full_name": "John Doe"},
"street": "Test street 12",
"zipcode": "12345",
"city": "Testington",
@@ -427,6 +434,9 @@ Fetching individual orders
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_name_parts": {
"full_name": "Peter",
},
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",
@@ -601,7 +611,7 @@ Creating orders
* ``company``
* ``is_business``
* ``name``
* ``name`` **or** ``name_parts``
* ``street``
* ``zipcode``
* ``city``
@@ -615,7 +625,7 @@ Creating orders
* ``item``
* ``variation``
* ``price``
* ``attendee_name``
* ``attendee_name`` **or** ``attendee_name_parts``
* ``attendee_email``
* ``secret`` (optional)
* ``addon_to`` (optional, see below)
@@ -664,7 +674,7 @@ Creating orders
"invoice_address": {
"is_business": False,
"company": "Sample company",
"name": "John Doe",
"name_parts": {"full_name": "John Doe"},
"street": "Sesam Street 12",
"zipcode": "12345",
"city": "Sample City",
@@ -678,7 +688,9 @@ Creating orders
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_name_parts": {
"full_name": "Peter"
},
"attendee_email": null,
"addon_to": null,
"answers": [
@@ -1075,6 +1087,9 @@ List of all order positions
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_name_parts": {
"full_name": "Peter"
},
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",
@@ -1172,6 +1187,9 @@ Fetching individual positions
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_name_parts": {
"full_name": "Peter",
},
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",

View File

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

View File

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

View File

@@ -231,6 +231,76 @@ Endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/vouchers/batch_create/
Creates multiple new vouchers atomically.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/vouchers/batch_create/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 408
[
{
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"valid_until": null,
"block_quota": false,
"allow_ignore_quota": false,
"price_mode": "set",
"value": "12.00",
"item": 1,
"variation": null,
"quota": null,
"tag": "testvoucher",
"comment": "",
"subevent": null
},
{
"code": "ASDKLJCYXCASDASD",
"max_usages": 1,
"valid_until": null,
"block_quota": false,
"allow_ignore_quota": false,
"price_mode": "set",
"value": "12.00",
"item": 1,
"variation": null,
"quota": null,
"tag": "testvoucher",
"comment": "",
"subevent": null
},
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
[
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
}, …
}
:param organizer: The ``slug`` field of the organizer to create a vouchers for
:param event: The ``slug`` field of the event to create a vouchers for
:statuscode 201: no error
:statuscode 400: The vouchers 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.
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/
Update a voucher. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of

View File

@@ -0,0 +1,243 @@
.. _`rest-webhooks`:
Webhooks
========
.. note:: This page is about how to modify webhook settings themselves through the REST API. If you just want to know
how webhooks work, go here: :ref:`webhooks`
Resource description
--------------------
The webhook resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the webhook
enabled boolean If ``False``, this webhook will not receive any notifications
target_url string The URL to call
all_events boolean If ``True``, this webhook will receive notifications
on all events of this organizer
limit_events list of strings If ``all_events`` is ``False``, this is a list of
event slugs this webhook is active for
action_types list of strings A list of action type filters that limit the
notifications sent to this webhook. See below for
valid values
===================================== ========================== =======================================================
The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.order.placed``
* ``pretix.event.order.paid``
* ``pretix.event.order.canceled``
* ``pretix.event.order.expired``
* ``pretix.event.order.modified``
* ``pretix.event.order.contact.changed``
* ``pretix.event.order.changed.*``
* ``pretix.event.order.refund.created.externally``
* ``pretix.event.order.refunded``
* ``pretix.event.order.approved``
* ``pretix.event.order.denied``
* ``pretix.event.checkin``
* ``pretix.event.checkin.reverted``
Installed plugins might register more valid values.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/webhooks/
Returns a list of all webhooks within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"enabled": true,
"target_url": "https://httpstat.us/200",
"all_events": false,
"limit_events": ["democon"],
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/webhooks/(id)/
Returns information on one webhook, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/webhooks/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 2,
"enabled": true,
"target_url": "https://httpstat.us/200",
"all_events": false,
"limit_events": ["democon"],
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the webhook to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/webhooks/
Creates a new webhook
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"enabled": true,
"target_url": "https://httpstat.us/200",
"all_events": false,
"limit_events": ["democon"],
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 3,
"enabled": true,
"target_url": "https://httpstat.us/200",
"all_events": false,
"limit_events": ["democon"],
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
}
:param organizer: The ``slug`` field of the organizer to create a webhook for
:statuscode 201: no error
:statuscode 400: The webhook 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)/webhooks/(id)/
Update a webhook. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/webhooks/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"enabled": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"enabled": false,
"target_url": "https://httpstat.us/200",
"all_events": false,
"limit_events": ["democon"],
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the webhook to modify
:statuscode 200: no error
:statuscode 400: The webhook could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/webhook/(id)/
Delete a webhook. Currently, this will not delete but just disable the webhook.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/webhooks/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the webhook to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.

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

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

108
doc/api/webhooks.rst Normal file
View File

@@ -0,0 +1,108 @@
.. _`webhooks`:
Webhooks
========
pretix can send webhook calls to notify your application of any changes that happen inside pretix. This is especially
useful for everything triggered by an actual user, such as a new ticket sale or the arrival of a payment.
You can register any number of webhook URLs that pretix will notify any time one of the supported events occurs inside
your organizer account. A great example use case of webhooks would be to add the buyer to your mailing list every time
a new order comes in.
Configuring webhooks
--------------------
You can find the list of your active webhooks in the "Webhook" section of your organizer account:
.. thumbnail:: ../screens/organizer/webhook_list.png
:align: center
:class: screenshot
Click "Create webhook" if you want to add a new URL. You will then be able to enter the URL pretix shall call for
notifications. You need to select any number of notification types that you want to receive and you can optionally
filter the events you want to receive notifications for.
.. thumbnail:: ../screens/organizer/webhook_edit.png
:align: center
:class: screenshot
You can also configure webhooks :ref:`through the API itself <rest-webhooks>`.
Receiving webhooks
------------------
Creating a webhook endpoint on your server is no different from creating any other page on your website. If your
website is written in PHP, you might just create a new ``.php`` file on your server; if you use a web framework like
Symfony or Django, you would just create a new route with the desired URL.
We will call your URL with a HTTP ``POST`` request with a ``JSON`` body. In PHP, you can parse this like this::
$input = @file_get_contents('php://input');
$event_json = json_decode($input);
// Do something with $event_json
In Django, you would create a view like this::
def my_webhook_view(request):
event_json = json.loads(request.body)
# Do something with event_json
return HttpResponse(status=200)
More samples for the language of your choice are easy to find online.
The exact body of the request varies by notification type, but for the main types included with pretix core, such as
those related to changes of an order, it will look like this::
{
"notification_id": 123455,
"organizer": "acmecorp",
"event": "democon",
"code": "ABC23",
"action": "pretix.event.order.placed"
}
Notifications regarding a check-in will contain more details like ``orderposition_id``
and ``checkin_list``.
.. warning:: You should not trust data supplied to your webhook, but only use it as a trigger to fetch updated data.
Anyone could send data there if they guess the correct URL and you won't be able to tell. Therefore, we
only include the minimum amount of data necessary for you to fetch the changed objects from our
:ref:`rest-api` in an authenticated way.
If you want to further prevent others from accessing your webhook URL, you can also use `Basic authentication`_ and
supply the URL to us in the format of ``https://username:password@domain.com/path/``.
We recommend that you use HTTPS for your webhook URL and might require it in the future. If HTTPS is used, we require
that a valid certificate is in use.
.. note:: If you use a web framework that makes use of automatic CSRF protection, this protection might prevent us
from calling your webhook URL. In this case, we recommend that you turn of CSRF protection selectively
for that route. In Django, you can do this by putting the ``@csrf_exempt`` decorator on your view. In
Rails, you can pass an ``except`` parameter to ``protect_from_forgery``.
Responding to a webhook
-----------------------
If you successfully received a webhook call, your endpoint should return a HTTP status code between ``200`` and ``299``.
If any other status code is returned, we will assume you did not receive the call. This does mean that any redirection
or ``304 Not Modified`` response will be treated as a failure. pretix will not follow any ``301`` or ``302`` redirect
headers and pretix will ignore all other information in your response headers or body.
If we do not receive a status code in the range of ``200`` and ``299``, pretix will retry to deliver for up to three
days with an exponential back off. Therefore, we recommend that you implement your endpoint in a way where calling it
multiple times for the same event due to a perceived error does not do any harm.
There is only one exception: If status code ``410 Gone`` is returned, we will assume the
endpoint does not exist any more and automatically disable the webhook.
.. note:: If you use a self-hosted version of pretix (i.e. not our SaaS offering at pretix.eu) and you did not
configure a background task queue, failed webhooks will not be retried.
Debugging webhooks
------------------
If you want to debug your webhooks, you can view a log of all sent notifications and the responses of your server for
30 days right next to your configuration.
.. _Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication

View File

@@ -64,6 +64,8 @@ The provider class
.. autoattribute:: settings_form_fields
.. automethod:: settings_form_clean
.. automethod:: settings_content_render
.. automethod:: is_allowed
@@ -96,8 +98,6 @@ The provider class
.. automethod:: order_change_allowed
.. automethod:: order_can_retry
.. automethod:: payment_prepare
.. automethod:: payment_control_render

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -23,6 +23,7 @@ cronjob
cryptographic
debian
deduplication
deprovision
discoverable
django
dockerfile
@@ -88,6 +89,7 @@ regex
renderer
renderers
reportlab
SaaS
screenshot
selectable
serializers
@@ -104,6 +106,7 @@ subevent
subevents
submodule
subpath
Symfony
systemd
testutils
timestamp

View File

@@ -1,12 +0,0 @@
[run]
source = pretix
omit = */migrations/*,*/urls.py,*/tests/*,*/testdummy/*,*/admin.py,pretix/wsgi.py,pretix/settings.py
[report]
exclude_lines =
pragma: no cover
def __str__
der __repr__
if settings.DEBUG
NOQA
NotImplementedError

View File

@@ -1 +1 @@
__version__ = "2.0.0"
__version__ = "2.2.0"

View File

@@ -5,5 +5,8 @@ class PretixApiConfig(AppConfig):
name = 'pretix.api'
label = 'pretixapi'
def ready(self):
from . import signals, webhooks # noqa
default_app_config = 'pretix.api.PretixApiConfig'

View File

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

View File

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

View File

@@ -10,7 +10,10 @@ def custom_exception_handler(exc, context):
if isinstance(exc, LockTimeoutException):
response = Response(
{'detail': 'The server was too busy to process your request. Please try again.'},
status=status.HTTP_409_CONFLICT
status=status.HTTP_409_CONFLICT,
headers={
'Retry-After': 5
}
)
return response

View File

@@ -0,0 +1,79 @@
# Generated by Django 2.1.1 on 2018-11-07 10:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0102_auto_20181017_0024'),
('pretixapi', '0002_auto_20180604_1120'),
]
operations = [
migrations.CreateModel(
name='WebHook',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('enabled', models.BooleanField(default=True, verbose_name='Enable webhook')),
('target_url', models.URLField(verbose_name='Target URL')),
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
('limit_events', models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events')),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Organizer')),
],
),
migrations.CreateModel(
name='WebHookCall',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datetime', models.DateTimeField(auto_now_add=True)),
('target_url', models.URLField()),
('is_retry', models.BooleanField(default=False)),
('execution_time', models.FloatField(null=True)),
('return_code', models.PositiveIntegerField(default=0)),
('payload', models.TextField()),
('response_body', models.TextField()),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
],
),
migrations.CreateModel(
name='WebHookEventListener',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
],
),
migrations.AddField(
model_name='webhookcall',
name='success',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='webhook',
name='all_events',
field=models.BooleanField(default=True, verbose_name='All events (including newly created ones)'),
),
migrations.AlterField(
model_name='webhook',
name='organizer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='pretixbase.Organizer'),
),
migrations.AlterField(
model_name='webhookcall',
name='webhook',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calls', to='pretixapi.WebHook'),
),
migrations.AlterField(
model_name='webhookeventlistener',
name='webhook',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listeners', to='pretixapi.WebHook'),
),
migrations.AddField(
model_name='webhookcall',
name='action_type',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
]

View File

@@ -68,3 +68,41 @@ class OAuthRefreshToken(AbstractRefreshToken):
OAuthAccessToken, on_delete=models.SET_NULL, blank=True, null=True,
related_name="refresh_token"
)
class WebHook(models.Model):
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
target_url = models.URLField(verbose_name=_("Target URL"))
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
@property
def action_types(self):
return [
l.action_type for l in self.listeners.all()
]
class WebHookEventListener(models.Model):
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='listeners')
action_type = models.CharField(max_length=255)
class Meta:
ordering = ("action_type",)
class WebHookCall(models.Model):
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
datetime = models.DateTimeField(auto_now_add=True)
target_url = models.URLField()
action_type = models.CharField(max_length=255)
is_retry = models.BooleanField(default=False)
execution_time = models.FloatField(null=True)
return_code = models.PositiveIntegerField(default=0)
success = models.BooleanField(default=False)
payload = models.TextField()
response_body = models.TextField()
class Meta:
ordering = ("-datetime",)

View File

@@ -19,18 +19,19 @@ class CartPositionSerializer(I18nAwareModelSerializer):
class Meta:
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers',)
class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers',)
def create(self, validated_data):
@@ -65,6 +66,11 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
quota.name
)
)
attendee_name = validated_data.pop('attendee_name', '')
if attendee_name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
@@ -118,4 +124,8 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
'You cannot specify a variation for this item.'
)
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
return data

View File

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

View File

@@ -35,11 +35,12 @@ class CompatibleCountryField(serializers.Field):
class InvoiceAddressSerializer(I18nAwareModelSerializer):
country = CompatibleCountryField(source='*')
name = serializers.CharField(required=False)
class Meta:
model = InvoiceAddress
fields = ('last_modified', 'is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
'vat_id_validated', 'internal_reference')
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
'vat_id', 'vat_id_validated', 'internal_reference')
read_only_fields = ('last_modified', 'vat_id_validated')
def __init__(self, *args, **kwargs):
@@ -48,6 +49,15 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
v.required = False
v.allow_blank = True
def validate(self, data):
if data.get('name') and data.get('name_parts'):
raise ValidationError(
{'name': ['Do not specify name if you specified name_parts.']}
)
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
return data
class AnswerQuestionIdentifierField(serializers.Field):
def to_representation(self, instance: QuestionAnswer):
@@ -77,6 +87,7 @@ class CheckinSerializer(I18nAwareModelSerializer):
class OrderDownloadsField(serializers.Field):
def to_representation(self, instance: Order):
if instance.status != Order.STATUS_PAID:
if instance.status != Order.STATUS_PENDING or instance.require_approval or not instance.event.settings.ticket_download_pending:
return []
request = self.context['request']
@@ -100,6 +111,7 @@ class OrderDownloadsField(serializers.Field):
class PositionDownloadsField(serializers.Field):
def to_representation(self, instance: OrderPosition):
if instance.order.status != Order.STATUS_PAID:
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
return []
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
return []
@@ -129,12 +141,19 @@ class PdfDataSerializer(serializers.Field):
res = {}
ev = instance.subevent or instance.order.event
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
# we serialize a list.
pdfvars = get_variables(instance.order.event)
for k, f in pdfvars.items():
if 'vars' not in self.context:
self.context['vars'] = get_variables(self.context['request'].event)
for k, f in self.context['vars'].items():
res[k] = f['evaluate'](instance, instance.order, ev)
for k, v in ev.meta_data.items():
if not hasattr(ev, '_cached_meta_data'):
ev._cached_meta_data = ev.meta_data
for k, v in ev._cached_meta_data.items():
res['meta:' + k] = v
return res
@@ -149,9 +168,9 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
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', 'pseudonymization_id', 'pdf_data')
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -296,10 +315,11 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers')
def validate_secret(self, secret):
@@ -350,6 +370,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'variation': ['You cannot specify a variation for this item.']}
)
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
return data
@@ -455,7 +481,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_info = validated_data.pop('payment_info', '{}')
if 'invoice_address' in validated_data:
ia = InvoiceAddress(**validated_data.pop('invoice_address'))
iadata = validated_data.pop('invoice_address')
name = iadata.pop('name', '')
if name and not iadata.get('name_parts'):
iadata['name_parts'] = {
'_legacy': name
}
ia = InvoiceAddress(**iadata)
else:
ia = None
@@ -507,6 +539,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if any(errs):
raise ValidationError({'positions': errs})
if validated_data.get('locale', None) is None:
validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data)
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
@@ -544,6 +578,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
addon_to = pos_data.pop('addon_to', None)
attendee_name = pos_data.pop('attendee_name', '')
if attendee_name and not pos_data.get('attendee_name_parts'):
pos_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
pos = OrderPosition(**pos_data)
pos.order = order
pos._calculate_tax()

View File

@@ -1,7 +1,27 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Voucher
class VoucherListSerializer(serializers.ListSerializer):
def create(self, validated_data):
codes = set()
errs = []
err = False
for voucher_data in validated_data:
if voucher_data['code'] in codes:
err = True
errs.append({'code': ['Duplicate voucher code in request.']})
else:
codes.add(voucher_data['code'])
errs.append({})
if err:
raise ValidationError(errs)
return super().create(validated_data)
class VoucherSerializer(I18nAwareModelSerializer):
class Meta:
model = Voucher
@@ -9,6 +29,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent')
read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer
def validate(self, data):
data = super().validate(data)

View File

@@ -0,0 +1,71 @@
from django.core.exceptions import ValidationError
from rest_framework import serializers
from pretix.api.models import WebHook
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.models import Event
class EventRelatedField(serializers.SlugRelatedField):
def get_queryset(self):
return self.context['organizer'].events.all()
class ActionTypesField(serializers.Field):
def to_representation(self, instance: WebHook):
return instance.action_types
def to_internal_value(self, data):
types = get_all_webhook_events()
for d in data:
if d not in types:
raise ValidationError('Invalid action type "%s".' % d)
return {'action_types': data}
class WebHookSerializer(I18nAwareModelSerializer):
limit_events = EventRelatedField(
slug_field='slug',
queryset=Event.objects.none(),
many=True
)
action_types = ActionTypesField(source='*')
class Meta:
model = WebHook
fields = ('id', 'enabled', 'target_url', 'all_events', 'limit_events', 'action_types')
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)
for event in full_data.get('limit_events'):
if self.context['organizer'] != event.organizer:
raise ValidationError('One or more events do not belong to this organizer.')
if full_data.get('limit_events') and full_data.get('all_events'):
raise ValidationError('You can set either limit_events or all_events.')
return data
def create(self, validated_data):
action_types = validated_data.pop('action_types')
inst = super().create(validated_data)
for l in action_types:
inst.listeners.create(action_type=l)
return inst
def update(self, instance, validated_data):
action_types = validated_data.pop('action_types', None)
instance = super().update(instance, validated_data)
if action_types is not None:
current_listeners = set(instance.listeners.values_list('action_type', flat=True))
new_listeners = set(action_types)
for l in current_listeners - new_listeners:
instance.listeners.filter(action_type=l).delete()
for l in new_listeners - current_listeners:
instance.listeners.create(action_type=l)
return instance

21
src/pretix/api/signals.py Normal file
View File

@@ -0,0 +1,21 @@
from datetime import timedelta
from django.dispatch import Signal, receiver
from django.utils.timezone import now
from pretix.api.models import WebHookCall
from pretix.base.signals import periodic_task
register_webhook_events = Signal(
providing_args=[]
)
"""
This signal is sent out to get all known webhook events. Receivers should return an
instance of a subclass of pretix.api.webhooks.WebhookEvent or a list of such
instances.
"""
@receiver(periodic_task)
def cleanup_webhook_logs(sender, **kwargs):
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()

View File

@@ -7,7 +7,8 @@ from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, event, item, oauth, order, organizer, voucher, waitinglist,
checkin, device, event, item, oauth, order, organizer, user, voucher,
waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -15,6 +16,8 @@ router.register(r'organizers', organizer.OrganizerViewSet)
orga_router = routers.DefaultRouter()
orga_router.register(r'events', event.EventViewSet)
orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet)
@@ -65,4 +68,9 @@ urlpatterns = [
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),
url(r"^device/initialize$", device.InitializeView.as_view(), name="device.initialize"),
url(r"^device/update$", device.UpdateView.as_view(), name="device.update"),
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
url(r"^me$", user.MeView.as_view(), name="user.me"),
]

View File

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

View File

@@ -154,7 +154,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('attendee_name', 'positionid')
ordering = ('attendee_name_cached', 'positionid')
ordering_fields = (
'order__code', 'order__datetime', 'positionid', 'attendee_name',
'last_checked_in', 'order__email',
@@ -162,11 +162,11 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
ordering_custom = {
'attendee_name': {
'_order': F('display_name').asc(nulls_first=True),
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
},
'-attendee_name': {
'_order': F('display_name').desc(nulls_last=True),
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
},
'last_checked_in': {
'_order': FixedOrderBy(F('last_checked_in'), nulls_first=True),
@@ -244,7 +244,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
ignore_unpaid=ignore_unpaid,
nonce=nonce,
datetime=dt,
questions_supported=self.request.data.get('questions_supported', True)
questions_supported=self.request.data.get('questions_supported', True),
user=self.request.user,
auth=self.request.auth,
)
except RequiredQuestionsError as e:
return Response({

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
filterset_class = ItemFilter
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -83,6 +83,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
user=self.request.user,
auth=self.request.auth,
)
self.get_object().cartposition_set.all().delete()
super().perform_destroy(instance)
@@ -92,7 +93,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter,)
ordering_fields = ('id', 'position')
ordering = ('id',)
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -154,7 +155,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter,)
ordering_fields = ('id', 'position')
ordering = ('id',)
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -210,7 +211,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
filterset_class = ItemCategoryFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -264,7 +265,8 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
filterset_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.questions.prefetch_related('options').all()
@@ -307,7 +309,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter,)
ordering_fields = ('id', 'position')
ordering = ('position',)
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -362,7 +364,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
filterset_class = QuotaFilter
ordering_fields = ('id', 'size')
ordering = ('id',)
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):

View File

@@ -3,8 +3,8 @@ 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.db.models import F, Prefetch, Q
from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now
@@ -25,7 +25,7 @@ from pretix.api.serializers.order import (
OrderRefundSerializer, OrderSerializer,
)
from pretix.base.models import (
Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
Device, Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
TeamAPIToken,
)
from pretix.base.payment import PaymentException
@@ -60,7 +60,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('datetime',)
ordering_fields = ('datetime', 'code', 'status')
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
filterset_class = OrderFilter
lookup_field = 'code'
permission = 'can_view_orders'
@@ -72,13 +72,34 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
return ctx
def get_queryset(self):
return self.request.event.orders.prefetch_related(
'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options',
'positions__answers__question', 'fees', 'payments', 'refunds', 'refunds__payment'
qs = self.request.event.orders.prefetch_related(
'fees', 'payments', 'refunds', 'refunds__payment'
).select_related(
'invoice_address'
)
if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related(
Prefetch(
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
)
)
)
else:
qs = qs.prefetch_related(
Prefetch(
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
)
)
)
return qs
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
@@ -177,6 +198,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
order,
user=request.user if request.user.is_authenticated else None,
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
device=request.auth if isinstance(request.auth, Device) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail
)
@@ -191,7 +213,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
approve_order(
order,
user=request.user if request.user.is_authenticated else None,
auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None,
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
send_mail=send_mail,
)
except Quota.QuotaExceededException as e:
@@ -210,7 +232,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
deny_order(
order,
user=request.user if request.user.is_authenticated else None,
auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None,
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
send_mail=send_mail,
comment=comment,
)
@@ -229,7 +251,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
)
order.status = Order.STATUS_PENDING
order.save()
order.save(update_fields=['status'])
order.log_action(
'pretix.event.order.unpaid',
user=request.user if request.user.is_authenticated else None,
@@ -267,7 +289,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
mark_order_refunded(
order,
user=request.user if request.user.is_authenticated else None,
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
auth=(request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken, Device)) else None),
)
return self.retrieve(request, [], **kwargs)
@@ -351,17 +373,17 @@ class OrderPositionFilter(FilterSet):
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(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value)
| Q(order__code__istartswith=value)
| Q(order__invoice_address__name__icontains=value)
| Q(order__invoice_address__name_cached__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__iexact=value) | Q(addon_to__attendee_name__iexact=value))
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
class Meta:
model = OrderPosition
@@ -387,6 +409,16 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
filterset_class = OrderPositionFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
ordering_custom = {
'attendee_name': {
'_order': F('display_name').asc(nulls_first=True),
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
},
'-attendee_name': {
'_order': F('display_name').asc(nulls_last=True),
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
},
}
def get_queryset(self):
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
@@ -534,7 +566,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
payment.order.event.subevents.filter(
id__in=payment.order.positions.values_list('subevent_id', flat=True))
)
payment.order.save()
payment.order.save(update_fields=['status', 'expires'])
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
@detail_route(methods=['POST'])
@@ -600,7 +632,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
refund.order.event.subevents.filter(
id__in=refund.order.positions.values_list('subevent_id', flat=True))
)
refund.order.save()
refund.order.save(update_fields=['status', 'expires'])
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])

View File

@@ -23,5 +23,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
)
else:
return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True))
elif hasattr(self.request.auth, 'organizer_id'):
return Organizer.objects.filter(pk=self.request.auth.organizer_id)
else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)

View File

@@ -0,0 +1,16 @@
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from rest_framework.views import APIView
class MeView(APIView):
authentication_classes = (SessionAuthentication, OAuth2Authentication)
def get(self, request, format=None):
return Response({
'email': request.user.email,
'fullname': request.user.fullname,
'locale': request.user.locale,
'timezone': request.user.timezone
})

View File

@@ -1,11 +1,16 @@
import contextlib
from django.db import transaction
from django.db.models import F, Q
from django.utils.timezone import now
from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet,
)
from rest_framework import viewsets
from rest_framework import status, viewsets
from rest_framework.decorators import list_route
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
from pretix.api.serializers.voucher import VoucherSerializer
from pretix.base.models import Voucher
@@ -41,8 +46,29 @@ class VoucherViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.vouchers.all()
def _predict_quota_check(self, data, instance):
# This method predicts if Voucher.clean_quota_needs_checking
# *migh* later require a quota check. It is only approximate
# and returns True a little too often. The point is to avoid
# locks when we know we won't need them.
if 'allow_ignore_quota' in data and data.get('allow_ignore_quota'):
return False
if instance and 'allow_ignore_quota' not in data and instance.allow_ignore_quota:
return False
if 'block_quota' in data and not data.get('block_quota'):
return False
if instance and 'block_quota' not in data and not instance.block_quota:
return False
return True
def create(self, request, *args, **kwargs):
with request.event.lock():
if self._predict_quota_check(request.data, None):
lockfn = request.event.lock
else:
lockfn = contextlib.suppress # noop context manager
with lockfn():
return super().create(request, *args, **kwargs)
def perform_create(self, serializer):
@@ -60,7 +86,11 @@ class VoucherViewSet(viewsets.ModelViewSet):
return ctx
def update(self, request, *args, **kwargs):
with request.event.lock():
if self._predict_quota_check(request.data, self.get_object()):
lockfn = request.event.lock
else:
lockfn = contextlib.suppress # noop context manager
with lockfn():
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
@@ -82,3 +112,24 @@ class VoucherViewSet(viewsets.ModelViewSet):
auth=self.request.auth,
)
super().perform_destroy(instance)
@list_route(methods=['POST'])
def batch_create(self, request, *args, **kwargs):
if any(self._predict_quota_check(d, None) for d in request.data):
lockfn = request.event.lock
else:
lockfn = contextlib.suppress # noop context manager
with lockfn():
serializer = self.get_serializer(data=request.data, many=True)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
serializer.save(event=self.request.event)
for i in serializer.instance:
i.log_action(
'pretix.voucher.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@@ -0,0 +1,49 @@
from rest_framework import viewsets
from pretix.api.models import WebHook
from pretix.api.serializers.webhooks import WebHookSerializer
from pretix.helpers.dicts import merge_dicts
class WebHookViewSet(viewsets.ModelViewSet):
serializer_class = WebHookSerializer
queryset = WebHook.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
def get_queryset(self):
return self.request.organizer.webhooks.prefetch_related('listeners')
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.webhook.created',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
def perform_update(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.webhook.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
return inst
def perform_destroy(self, instance):
self.request.organizer.log_action(
'pretix.webhook.changed',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk, 'enabled': False}
)
instance.enabled = False
instance.save(update_fields=['enabled'])

252
src/pretix/api/webhooks.py Normal file
View File

@@ -0,0 +1,252 @@
import json
import logging
import time
from collections import OrderedDict
import requests
from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from requests import RequestException
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
from pretix.api.signals import register_webhook_events
from pretix.base.models import LogEntry
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.celery_app import app
logger = logging.getLogger(__name__)
_ALL_EVENTS = None
class WebhookEvent:
def __init__(self):
pass
def __repr__(self):
return '<WebhookEvent: {}>'.format(self.action_type)
@property
def action_type(self) -> str:
"""
The action_type string that this notification handles, for example
``"pretix.event.order.paid"``. Only one notification type should be registered
per action type.
"""
raise NotImplementedError() # NOQA
@property
def verbose_name(self) -> str:
"""
A human-readable name of this notification type.
"""
raise NotImplementedError() # NOQA
def build_payload(self, logentry: LogEntry) -> dict:
"""
This is the main function that you should override. It is supposed to turn a log entry
object into a dictionary that can be used as the webhook payload.
"""
raise NotImplementedError() # NOQA
def get_all_webhook_events():
global _ALL_EVENTS
if _ALL_EVENTS:
return _ALL_EVENTS
types = OrderedDict()
for recv, ret in register_webhook_events.send(None):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.action_type] = r
else:
types[ret.action_type] = ret
_ALL_EVENTS = types
return types
class ParametrizedOrderWebhookEvent(WebhookEvent):
def __init__(self, action_type, verbose_name):
self._action_type = action_type
self._verbose_name = verbose_name
super().__init__()
@property
def action_type(self):
return self._action_type
@property
def verbose_name(self):
return self._verbose_name
def build_payload(self, logentry: LogEntry):
order = logentry.content_object
return {
'notification_id': logentry.pk,
'organizer': order.event.organizer.slug,
'event': order.event.slug,
'code': order.code,
'action': logentry.action_type,
}
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
def build_payload(self, logentry: LogEntry):
d = super().build_payload(logentry)
d['orderposition_id'] = logentry.parsed_data.get('position')
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
d['checkin_list'] = logentry.parsed_data.get('list')
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
def register_default_webhook_events(sender, **kwargs):
return (
ParametrizedOrderWebhookEvent(
'pretix.event.order.placed',
_('New order placed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.paid',
_('Order marked as paid'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.canceled',
_('Order canceled'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.expired',
_('Order expired'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.modified',
_('Order information changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.contact.changed',
_('Order contact address changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.changed.*',
_('Order changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.created.externally',
_('External refund of payment'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refunded',
_('Order refunded'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.approved',
_('Order approved'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.denied',
_('Order denied'),
),
ParametrizedOrderPositionWebhookEvent(
'pretix.event.checkin',
_('Ticket checked in'),
),
ParametrizedOrderPositionWebhookEvent(
'pretix.event.checkin.reverted',
_('Ticket check-in reverted'),
),
)
@app.task(base=TransactionAwareTask)
def notify_webhooks(logentry_id: int):
logentry = LogEntry.all.get(id=logentry_id)
if not logentry.organizer:
return # We need to know the organizer
types = get_all_webhook_events()
notification_type = None
typepath = logentry.action_type
while not notification_type and '.' in typepath:
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
if not notification_type:
return # Ignore, no webhooks for this event type
# All webhooks that registered for this notification
event_listener = WebHookEventListener.objects.filter(
webhook=OuterRef('pk'),
action_type=notification_type.action_type
)
webhooks = WebHook.objects.annotate(has_el=Exists(event_listener)).filter(
organizer=logentry.organizer,
has_el=True,
enabled=True
)
if logentry.event_id:
webhooks = webhooks.filter(
Q(all_events=True) | Q(limit_events__pk=logentry.event_id)
)
for wh in webhooks:
send_webhook.apply_async(args=(logentry_id, notification_type.action_type, wh.pk))
@app.task(base=ProfiledTask, bind=True, max_retries=9)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours
logentry = LogEntry.all.get(id=logentry_id)
webhook = WebHook.objects.get(id=webhook_id)
types = get_all_webhook_events()
event_type = types.get(action_type)
if not event_type or not webhook.enabled:
return # Ignore, e.g. plugin not installed
payload = event_type.build_payload(logentry)
t = time.time()
try:
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False
)
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
raise self.retry(countdown=2 ** (self.request.retries * 2))
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2))
except MaxRetriesExceededError:
pass

View File

@@ -1,5 +1,5 @@
import logging
from smtplib import SMTPRecipientsRefused, SMTPSenderRefused
from smtplib import SMTPResponseException
import bleach
import markdown
@@ -23,16 +23,14 @@ class CustomSMTPBackend(EmailBackend):
try:
self.open()
self.connection.ehlo_or_helo_if_needed()
self.connection.rcpt("test@example.org")
(code, resp) = self.connection.mail(from_addr, [])
if code != 250:
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPSenderRefused(code, resp, from_addr)
senderrs = {}
raise SMTPResponseException(code, resp)
(code, resp) = self.connection.rcpt('test@example.com')
if (code != 250) and (code != 251):
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPRecipientsRefused(senderrs)
raise SMTPResponseException(code, resp)
finally:
self.close()
@@ -97,7 +95,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
@property
def template_name(self):
raise NotImplemented
raise NotImplementedError()
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
body_md = bleach.linkify(markdown_compile(plain_body))

View File

@@ -27,7 +27,7 @@ class InvoiceExporter(BaseExporter):
qs = qs.annotate(
has_payment_with_provider=Exists(
OrderPayment.objects.filter(
Q(order=OuterRef('pk')) & Q(provider=form_data.get('payment_provider'))
Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider'))
)
)
)

View File

@@ -12,6 +12,7 @@ from django.utils.translation import ugettext as _, ugettext_lazy
from pretix.base.models import InvoiceAddress, Order, OrderPosition
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.settings import PERSON_NAME_SCHEMES
from ..exporter import BaseExporter
from ..signals import register_data_exporters
@@ -74,7 +75,14 @@ class OrderListExporter(BaseExporter):
headers = [
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
_('Company'), _('Name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
if len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(label)
headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
_('Date of last payment'), _('Fees'), _('Order locale')
]
@@ -118,6 +126,13 @@ class OrderListExporter(BaseExporter):
row += [
order.invoice_address.company,
order.invoice_address.name,
]
if len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
order.invoice_address.name_parts.get(k, '')
)
row += [
order.invoice_address.street,
order.invoice_address.zipcode,
order.invoice_address.city,
@@ -126,7 +141,7 @@ class OrderListExporter(BaseExporter):
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
row += ['', '', '', '', '', '', '']
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',

View File

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

View File

@@ -1,3 +1,4 @@
import copy
import logging
from decimal import Decimal
@@ -8,6 +9,7 @@ import vat_moss.id
from django import forms
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from pretix.base.forms.widgets import (
@@ -16,12 +18,112 @@ from pretix.base.forms.widgets import (
)
from pretix.base.models import InvoiceAddress, Question
from pretix.base.models.tax import EU_COUNTRIES
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import SplitDateTimeField
from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__)
class NamePartsWidget(forms.MultiWidget):
widget = forms.TextInput
def __init__(self, scheme: dict, field: forms.Field, attrs=None):
widgets = []
self.scheme = scheme
self.field = field
for fname, label, size in self.scheme['fields']:
a = copy.copy(attrs) or {}
a['data-fname'] = fname
widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs)
def decompress(self, value):
if value is None:
return None
data = []
for i, field in enumerate(self.scheme['fields']):
fname, label, size = field
data.append(value.get(fname, ""))
if '_legacy' in value and not data[-1]:
data[-1] = value.get('_legacy', '')
return data
def render(self, name: str, value, attrs=None, renderer=None) -> str:
if not isinstance(value, list):
value = self.decompress(value)
output = []
final_attrs = self.build_attrs(attrs or dict())
if 'required' in final_attrs:
del final_attrs['required']
id_ = final_attrs.get('id', None)
for i, widget in enumerate(self.widgets):
try:
widget_value = value[i]
except (IndexError, TypeError):
widget_value = None
if id_:
final_attrs = dict(
final_attrs,
id='%s_%s' % (id_, i),
title=self.scheme['fields'][i][1],
placeholder=self.scheme['fields'][i][1],
)
final_attrs['data-size'] = self.scheme['fields'][i][2]
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer))
return mark_safe(self.format_output(output))
def format_output(self, rendered_widgets) -> str:
return '<div class="nameparts-form-group">%s</div>' % ''.join(rendered_widgets)
class NamePartsFormField(forms.MultiValueField):
widget = NamePartsWidget
def compress(self, data_list) -> dict:
data = {}
data['_scheme'] = self.scheme_name
for i, value in enumerate(data_list):
data[self.scheme['fields'][i][0]] = value or ''
return data
def __init__(self, *args, **kwargs):
fields = []
defaults = {
'widget': self.widget,
'max_length': kwargs.pop('max_length', None),
}
self.scheme_name = kwargs.pop('scheme')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
self.one_required = kwargs.get('required', True)
require_all_fields = kwargs.pop('require_all_fields', False)
kwargs['required'] = False
kwargs['widget'] = (kwargs.get('widget') or self.widget)(
scheme=self.scheme, field=self, **kwargs.pop('widget_kwargs', {})
)
defaults.update(**kwargs)
for fname, label, size in self.scheme['fields']:
defaults['label'] = label
field = forms.CharField(**defaults)
field.part_name = fname
fields.append(field)
super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs
)
self.require_all_fields = require_all_fields
self.required = self.one_required
def clean(self, value) -> dict:
value = super().clean(value)
if self.one_required and (not value or not any(v for v in value)):
raise forms.ValidationError(self.error_messages['required'], code='required')
if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
return value
class BaseQuestionsForm(forms.Form):
"""
This form class is responsible for asking order-related questions. This includes
@@ -46,10 +148,12 @@ class BaseQuestionsForm(forms.Form):
super().__init__(*args, **kwargs)
if item.admission and event.settings.attendee_names_asked:
self.fields['attendee_name'] = forms.CharField(
max_length=255, required=event.settings.attendee_names_required,
self.fields['attendee_name_parts'] = NamePartsFormField(
max_length=255,
required=event.settings.attendee_names_required,
scheme=event.settings.name_scheme,
label=_('Attendee name'),
initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name),
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
)
if item.admission and event.settings.attendee_emails_asked:
self.fields['attendee_email'] = forms.EmailField(
@@ -66,6 +170,7 @@ class BaseQuestionsForm(forms.Form):
else:
initial = None
tz = pytz.timezone(event.settings.timezone)
help_text = rich_text(q.help_text)
if q.type == Question.TYPE_BOOLEAN:
if q.required:
# For some reason, django-bootstrap3 does not set the required attribute
@@ -81,7 +186,7 @@ class BaseQuestionsForm(forms.Form):
field = forms.BooleanField(
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
initial=initialbool, widget=widget,
)
elif q.type == Question.TYPE_NUMBER:
@@ -94,13 +199,13 @@ class BaseQuestionsForm(forms.Form):
elif q.type == Question.TYPE_STRING:
field = forms.CharField(
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_TEXT:
field = forms.CharField(
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
widget=forms.Textarea,
initial=initial.answer if initial else None,
)
@@ -108,7 +213,7 @@ class BaseQuestionsForm(forms.Form):
field = forms.ModelChoiceField(
queryset=q.options,
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
widget=forms.Select,
empty_label='',
initial=initial.options.first() if initial else None,
@@ -117,35 +222,35 @@ class BaseQuestionsForm(forms.Form):
field = forms.ModelMultipleChoiceField(
queryset=q.options,
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
widget=forms.CheckboxSelectMultiple,
initial=initial.options.all() if initial else None,
)
elif q.type == Question.TYPE_FILE:
field = forms.FileField(
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
initial=initial.file if initial else None,
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
)
elif q.type == Question.TYPE_DATE:
field = forms.DateField(
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
widget=DatePickerWidget(),
)
elif q.type == Question.TYPE_TIME:
field = forms.TimeField(
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
elif q.type == Question.TYPE_DATETIME:
field = forms.SplitDateTimeField(
field = SplitDateTimeField(
label=q.question, required=q.required,
help_text=q.help_text,
help_text=help_text,
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
@@ -169,13 +274,12 @@ class BaseInvoiceAddressForm(forms.ModelForm):
class Meta:
model = InvoiceAddress
fields = ('is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
'internal_reference')
widgets = {
'is_business': BusinessBooleanRadio,
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'name': forms.TextInput(attrs={}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'internal_reference': forms.TextInput,
}
@@ -190,15 +294,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid:
del self.fields['vat_id']
if not event.settings.invoice_address_required:
for k, f in self.fields.items():
f.required = False
f.widget.is_required = False
if 'required' in f.widget.attrs:
del f.widget.attrs['required']
if event.settings.invoice_name_required:
self.fields['name'].required = True
elif event.settings.invoice_address_company_required:
self.initial['is_business'] = True
@@ -209,18 +311,34 @@ class BaseInvoiceAddressForm(forms.ModelForm):
del self.fields['company'].widget.attrs['data-display-dependency']
if 'vat_id' in self.fields:
del self.fields['vat_id'].widget.attrs['data-display-dependency']
else:
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=event.settings.invoice_name_required,
scheme=event.settings.name_scheme,
label=_('Name'),
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
)
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required:
self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
def clean(self):
data = self.cleaned_data
if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required:
raise ValidationError(_('You need to provide either a company name or your name.'))
if not data.get('is_business'):
data['company'] = ''
if self.event.settings.invoice_address_required:
if data.get('is_business') and not data.get('company'):
raise ValidationError(_('You need to provide a company name.'))
if not data.get('is_business') and not data.get('name_parts'):
raise ValidationError(_('You need to provide your name.'))
if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False
self.instance.name_parts = data.get('name_parts')
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
@@ -232,7 +350,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
country_code, normalized_id, company_name = result
self.instance.vat_id_validated = True
self.instance.vat_id = normalized_id
except vat_moss.errors.InvalidError:
except (vat_moss.errors.InvalidError, ValueError):
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
except vat_moss.errors.WebServiceUnavailableError:
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))

View File

@@ -2,6 +2,7 @@ import os
from django import forms
from django.utils.formats import get_format
from django.utils.functional import lazy
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
@@ -92,15 +93,21 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
date_attrs['class'] += ' datepickerfield'
time_attrs['class'] += ' timepickerfield'
def date_placeholder():
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
date_attrs['placeholder'] = now().replace(
return now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df)
def time_placeholder():
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
time_attrs['placeholder'] = now().replace(
return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf)
date_attrs['placeholder'] = lazy(date_placeholder, str)
time_attrs['placeholder'] = lazy(time_placeholder, str)
widgets = (
forms.DateInput(attrs=date_attrs, format=date_format),
forms.TimeInput(attrs=time_attrs, format=time_format),

View File

@@ -192,8 +192,15 @@ class ThumbnailingImageReader(ImageReader):
size=(int(width * dpi / 72), int(height * dpi / 72)),
resample=BICUBIC
)
self._data = None
return width, height
def _jpeg_fh(self):
# Bypass a reportlab-internal optimization that falls back to the original
# file handle if the file is a JPEG, and therefore does not respect the
# (smaller) size of the modified image.
return None
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
identifier = 'classic'
@@ -216,7 +223,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
def _draw_invoice_from(self, canvas):
p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 70 * mm, 50 * mm)
p_size = p.wrap(70 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
@@ -323,7 +330,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return txt
if not self.invoice.event.has_subevents:
if self.invoice.event.settings.show_date_to:
if self.invoice.event.settings.show_date_to and self.invoice.event.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(),
@@ -379,7 +386,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.internal_reference:
story.append(Paragraph(
pgettext('invoice', 'Your reference: {reference}').format(reference=self.invoice.internal_reference),
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
self.stylesheet['Normal']
))

View File

@@ -28,7 +28,8 @@ class Migration(migrations.Migration):
('password', models.CharField(verbose_name='password', max_length=128)),
('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)),
('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')),
('email', models.EmailField(max_length=254, blank=True, unique=True, verbose_name='E-mail', null=True, db_index=True)),
('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='E-mail', null=True,
db_index=True)),
('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)),
('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)),
('is_active', models.BooleanField(verbose_name='Is active', default=True)),

View File

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

View File

@@ -0,0 +1,79 @@
# Generated by Django 2.1 on 2018-10-23 23:00
import django_countries.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0099_auto_20180912_1035'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='invoice_from_city',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_from_country',
field=django_countries.fields.CountryField(max_length=2, null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_from_name',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_from_tax_id',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_from_vat_id',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_from_zipcode',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_city',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_company',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_country',
field=django_countries.fields.CountryField(max_length=2, null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_name',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_street',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_vat_id',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_zipcode',
field=models.CharField(max_length=190, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.1 on 2018-10-25 22:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0100_auto_20181023_2300'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='reverse_charge',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,96 @@
# Generated by Django 2.1 on 2018-10-17 00:24
import jsonfallback.fields
from django.core.exceptions import ImproperlyConfigured
from django.db import migrations
from django_mysql.checks import mysql_connections
from django_mysql.utils import connection_is_mariadb
def set_attendee_name_parts(apps, schema_editor):
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
for op in OrderPosition.objects.exclude(attendee_name_cached=None).exclude(
attendee_name_cached__isnull=True).iterator():
op.attendee_name_parts = {'_legacy': op.attendee_name_cached}
op.save(update_fields=['attendee_name_parts'])
CartPosition = apps.get_model('pretixbase', 'CartPosition') # noqa
for op in CartPosition.objects.exclude(attendee_name_cached=None).exclude(
attendee_name_cached__isnull=True).iterator():
op.attendee_name_parts = {'_legacy': op.attendee_name_cached}
op.save(update_fields=['attendee_name_parts'])
InvoiceAddress = apps.get_model('pretixbase', 'InvoiceAddress') # noqa
for ia in InvoiceAddress.objects.exclude(name_cached=None).exclude(
name_cached__isnull=True).iterator():
ia.name_parts = {'_legacy': ia.name_cached}
ia.save(update_fields=['name_parts'])
def check_mysqlversion(apps, schema_editor):
errors = []
any_conn_works = False
conns = list(mysql_connections())
found = 'Unknown version'
for alias, conn in conns:
if connection_is_mariadb(conn) and hasattr(conn, 'mysql_version'):
if conn.mysql_version >= (10, 2, 7):
any_conn_works = True
else:
found = 'MariaDB ' + '.'.join(str(v) for v in conn.mysql_version)
elif hasattr(conn, 'mysql_version'):
if conn.mysql_version >= (5, 7):
any_conn_works = True
else:
found = 'MySQL ' + '.'.join(str(v) for v in conn.mysql_version)
if conns and not any_conn_works:
raise ImproperlyConfigured(
'As of pretix 2.2, you need MySQL 5.7+ or MariaDB 10.2.7+ to run pretix. However, we detected a '
'database connection to {}'.format(found)
)
return errors
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0101_auto_20181025_2255'),
]
operations = [
migrations.RunPython(
check_mysqlversion, migrations.RunPython.noop
),
migrations.RenameField(
model_name='cartposition',
old_name='attendee_name',
new_name='attendee_name_cached',
),
migrations.RenameField(
model_name='orderposition',
old_name='attendee_name',
new_name='attendee_name_cached',
),
migrations.RenameField(
model_name='invoiceaddress',
old_name='name',
new_name='name_cached',
),
migrations.AddField(
model_name='cartposition',
name='attendee_name_parts',
field=jsonfallback.fields.FallbackJSONField(null=False, default=dict),
preserve_default=False,
),
migrations.AddField(
model_name='orderposition',
name='attendee_name_parts',
field=jsonfallback.fields.FallbackJSONField(null=False, default=dict),
preserve_default=False,
),
migrations.AddField(
model_name='invoiceaddress',
name='name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
preserve_default=False,
),
migrations.RunPython(set_attendee_name_parts, migrations.RunPython.noop)
]

View File

@@ -2,6 +2,7 @@ from ..settings import GlobalSettingsObject_SettingsStore
from .auth import U2FDevice, User
from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin, CheckinList
from .devices import Device
from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,

View File

@@ -75,7 +75,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
REQUIRED_FIELDS = []
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('E-mail'))
verbose_name=_('E-mail'), max_length=190)
fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name'))
is_active = models.BooleanField(default=True,

View File

@@ -3,6 +3,7 @@ import uuid
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.utils.crypto import get_random_string
@@ -47,10 +48,12 @@ class LoggingMixin:
"""
from .log import LogEntry
from .event import Event
from .devices import Device
from pretix.api.models import OAuthAccessToken, OAuthApplication
from .organizer import TeamAPIToken
from ..notifications import get_all_notification_types
from ..services.notifications import notify
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
event = None
if isinstance(self, Event):
@@ -67,6 +70,8 @@ class LoggingMixin:
kwargs['oauth_application'] = auth
elif isinstance(auth, TeamAPIToken):
kwargs['api_token'] = auth
elif isinstance(auth, Device):
kwargs['device'] = auth
elif isinstance(api_token, TeamAPIToken):
kwargs['api_token'] = api_token
@@ -76,8 +81,21 @@ class LoggingMixin:
if save:
logentry.save()
if action in get_all_notification_types():
no_types = get_all_notification_types()
wh_types = get_all_webhook_events()
no_type = None
wh_type = None
typepath = logentry.action_type
while (not no_type or not wh_types) and '.' in typepath:
wh_type = wh_type or wh_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
no_type = no_type or no_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
if no_type:
notify.apply_async(args=(logentry.pk,))
if wh_type:
notify_webhooks.apply_async(args=(logentry.pk,))
return logentry
@@ -96,4 +114,50 @@ class LoggedModel(models.Model, LoggingMixin):
return LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
).select_related('user', 'event', 'oauth_application', 'api_token')
).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
class LockModel:
def refresh_for_update(self, fields=None, using=None, **kwargs):
"""
Like refresh_from_db(), but with select_for_update().
See also https://code.djangoproject.com/ticket/28344
"""
if fields is not None:
if not fields:
return
if any(LOOKUP_SEP in f for f in fields):
raise ValueError(
'Found "%s" in fields argument. Relations and transforms '
'are not allowed in fields.' % LOOKUP_SEP)
hints = {'instance': self}
db_instance_qs = self.__class__._base_manager.db_manager(using, hints=hints).filter(pk=self.pk).select_for_update(**kwargs)
# Use provided fields, if not set then reload all non-deferred fields.
deferred_fields = self.get_deferred_fields()
if fields is not None:
fields = list(fields)
db_instance_qs = db_instance_qs.only(*fields)
elif deferred_fields:
fields = [f.attname for f in self._meta.concrete_fields
if f.attname not in deferred_fields]
db_instance_qs = db_instance_qs.only(*fields)
db_instance = db_instance_qs.get()
non_loaded_fields = db_instance.get_deferred_fields()
for field in self._meta.concrete_fields:
if field.attname in non_loaded_fields:
# This field wasn't refreshed - skip ahead.
continue
setattr(self, field.attname, getattr(db_instance, field.attname))
# Clear cached foreign keys.
if field.is_relation and field.is_cached(self):
field.delete_cached_value(self)
# Clear cached relations.
for field in self._meta.related_objects:
if field.is_cached(self):
field.delete_cached_value(self)
self._state.db = db_instance._state.db

View File

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

View File

@@ -276,12 +276,24 @@ class Event(EventMixin, LoggedModel):
else:
return super().presale_has_ended
def delete_all_orders(self, really=False):
from .orders import OrderRefund, OrderPayment, OrderPosition, OrderFee
if not really:
raise TypeError("Pass really=True as a parameter.")
OrderPosition.objects.filter(order__event=self).delete()
OrderFee.objects.filter(order__event=self).delete()
OrderPayment.objects.filter(order__event=self).delete()
OrderRefund.objects.filter(order__event=self).delete()
self.orders.all().delete()
def save(self, *args, **kwargs):
obj = super().save(*args, **kwargs)
self.cache.clear()
return obj
def get_plugins(self) -> "list[str]":
def get_plugins(self):
"""
Returns the names of the plugins activated for this event as a list.
"""
@@ -289,7 +301,7 @@ class Event(EventMixin, LoggedModel):
return []
return self.plugins.split(",")
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
Django's built-in cache backends, but puts you into an isolated environment for

View File

@@ -5,6 +5,8 @@ from django.db import DatabaseError, models, transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import pgettext
from django_countries.fields import CountryField
def invoice_filename(instance, filename: str) -> str:
@@ -73,11 +75,25 @@ class Invoice(models.Model):
is_cancellation = models.BooleanField(default=False)
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True, on_delete=models.CASCADE)
invoice_from = models.TextField()
invoice_from_name = models.CharField(max_length=190, null=True)
invoice_from_zipcode = models.CharField(max_length=190, null=True)
invoice_from_city = models.CharField(max_length=190, null=True)
invoice_from_country = CountryField(null=True)
invoice_from_tax_id = models.CharField(max_length=190, null=True)
invoice_from_vat_id = models.CharField(max_length=190, null=True)
invoice_to = models.TextField()
invoice_to_company = models.TextField(null=True)
invoice_to_name = models.TextField(null=True)
invoice_to_street = models.TextField(null=True)
invoice_to_zipcode = models.CharField(max_length=190, null=True)
invoice_to_city = models.TextField(null=True)
invoice_to_country = CountryField(null=True)
invoice_to_vat_id = models.TextField(null=True)
date = models.DateField(default=today)
locale = models.CharField(max_length=50, default='en')
introductory_text = models.TextField(blank=True)
additional_text = models.TextField(blank=True)
reverse_charge = models.BooleanField(default=False)
payment_provider_text = models.TextField(blank=True)
footer_text = models.TextField(blank=True)
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
@@ -92,6 +108,18 @@ class Invoice(models.Model):
def _to_numeric_invoice_number(number):
return '{:05d}'.format(int(number))
@property
def full_invoice_from(self):
parts = [
self.invoice_from_name,
self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
str(self.invoice_from_country),
pgettext("invoice", "VAT-ID: %s" % self.invoice_from_vat_id) if self.invoice_from_vat_id else "",
pgettext("invoice", "Tax ID: %s" % self.invoice_from_tax_id) if self.invoice_from_tax_id else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
def _get_numeric_invoice_number(self):
numeric_invoices = Invoice.objects.filter(
event__organizer=self.event.organizer,

View File

@@ -403,12 +403,9 @@ class Item(LoggedModel):
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
def allow_delete(self):
from pretix.base.models.orders import CartPosition, OrderPosition
from pretix.base.models.orders import OrderPosition
return (
not OrderPosition.objects.filter(item=self).exists()
and not CartPosition.objects.filter(item=self).exists()
)
return not OrderPosition.objects.filter(item=self).exists()
@cached_property
def has_variations(self):

View File

@@ -41,6 +41,7 @@ class LogEntry(models.Model):
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT)
device = models.ForeignKey('Device', null=True, blank=True, on_delete=models.PROTECT)
oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL)
action_type = models.CharField(max_length=255)
@@ -62,6 +63,16 @@ class LogEntry(models.Model):
return response
return self.action_type
@cached_property
def organizer(self):
if self.event:
return self.event.organizer
elif hasattr(self.content_object, 'event'):
return self.content_object.event.organizer
elif hasattr(self.content_object, 'organizer'):
return self.content_object.organizer
return None
@cached_property
def display_object(self):
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent

View File

@@ -26,12 +26,14 @@ from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import CountryField
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
from pretix.base.i18n import language
from pretix.base.models import User
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES
from .base import LoggedModel
from .base import LockModel, LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota
@@ -47,7 +49,7 @@ def generate_position_secret():
return get_random_string(length=settings.ENTROPY['ticket_secret'], allowed_chars='abcdefghjkmnpqrstuvwxyz23456789')
class Order(LoggedModel):
class Order(LockModel, LoggedModel):
"""
An order is created when a user clicks 'buy' on his cart. It holds
several OrderPositions and is connected to a user. It has an
@@ -420,6 +422,20 @@ class Order(LoggedModel):
dl_date = dl_date.datetime(self.event)
return dl_date
@property
def ticket_download_available(self):
return self.event.settings.ticket_download and (
self.event.settings.ticket_download_date is None
or now() > self.ticket_download_date
) and (
self.status == Order.STATUS_PAID
or (
(self.event.settings.ticket_download_pending or self.total == Decimal("0.00")) and
self.status == Order.STATUS_PENDING and
not self.require_approval
)
)
@property
def payment_term_last(self):
tz = pytz.timezone(self.event.settings.timezone)
@@ -442,7 +458,7 @@ class Order(LoggedModel):
error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."),
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
'late': _("The payment can not be accepted as the order is expired and you configured that no late "
"payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.')
}
@@ -496,7 +512,7 @@ class Order(LoggedModel):
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None):
auth=None, attach_tickets=False):
"""
Sends an email to the user that placed this order. Basically, this method does two things:
@@ -512,6 +528,7 @@ class Order(LoggedModel):
:param user: Administrative user who triggered this mail to be sent
:param headers: Dictionary with additional mail headers
:param sender: Custom email sender.
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
"""
from pretix.base.services.mail import SendMailException, mail, render_mail
@@ -525,7 +542,7 @@ class Order(LoggedModel):
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers, sender,
invoices=invoices
invoices=invoices, attach_tickets=attach_tickets
)
except SendMailException:
raise
@@ -684,8 +701,10 @@ class AbstractPosition(models.Model):
:type expires: datetime
:param price: The price of this item
:type price: decimal.Decimal
:param attendee_name: The attendee's name, if entered.
:type attendee_name: str
:param attendee_name_parts: The parts of the attendee's name, if entered.
:type attendee_name_parts: str
:param attendee_name_cached: The concatenated version of the attendee's name, if entered.
:type attendee_name_cached: str
:param attendee_email: The attendee's email, if entered.
:type attendee_email: str
:param voucher: A voucher that has been applied to this sale
@@ -714,12 +733,15 @@ class AbstractPosition(models.Model):
decimal_places=2, max_digits=10,
verbose_name=_("Price")
)
attendee_name = models.CharField(
attendee_name_cached = models.CharField(
max_length=255,
verbose_name=_("Attendee name"),
blank=True, null=True,
help_text=_("Empty, if this product is not an admission ticket")
)
attendee_name_parts = FallbackJSONField(
blank=True, default=dict
)
attendee_email = models.EmailField(
verbose_name=_("Attendee email"),
blank=True, null=True,
@@ -782,6 +804,24 @@ class AbstractPosition(models.Model):
if self.variation is None
else self.variation.quotas.filter(subevent=self.subevent))
def save(self, *args, **kwargs):
self.attendee_name_cached = self.attendee_name
if self.attendee_name_parts is None:
self.attendee_name_parts = {}
super().save(*args, **kwargs)
@property
def attendee_name(self):
if not self.attendee_name_parts:
return None
if '_legacy' in self.attendee_name_parts:
return self.attendee_name_parts['_legacy']
if '_scheme' in self.attendee_name_parts:
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
else:
scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
return scheme['concatenation'](self.attendee_name_parts).strip()
class OrderPayment(models.Model):
"""
@@ -882,6 +922,25 @@ class OrderPayment(models.Model):
"""
return self.order.event.get_payment_providers().get(self.provider)
def _mark_paid(self, force, count_waitinglist, user, auth):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
if not force and can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid
}, user=user, auth=auth)
raise Quota.QuotaExceededException(can_be_paid)
self.order.status = Order.STATUS_PAID
self.order.save(update_fields=['status'])
self.order.log_action('pretix.event.order.paid', {
'provider': self.provider,
'info': self.info,
'date': self.payment_date,
'force': force
}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''):
"""
Marks the payment as complete. If possible, this also marks the order as paid if no further
@@ -901,7 +960,6 @@ class OrderPayment(models.Model):
:type mail_text: str
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
"""
from pretix.base.signals import order_paid
from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -928,20 +986,14 @@ class OrderPayment(models.Model):
if payment_sum - refund_sum < self.order.total:
return
if self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12):
# Performance optimization. In this case, there's really no reason to lock everything and an atomic
# database transaction is more than enough.
with transaction.atomic():
self._mark_paid(force, count_waitinglist, user, auth)
else:
with self.order.event.lock():
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
if not force and can_be_paid is not True:
raise Quota.QuotaExceededException(can_be_paid)
self.order.status = Order.STATUS_PAID
self.order.save()
self.order.log_action('pretix.event.order.paid', {
'provider': self.provider,
'info': self.info,
'date': self.payment_date,
'force': force
}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order)
self._mark_paid(force, count_waitinglist, user, auth)
invoice = None
if invoice_qualified(self.order):
@@ -982,7 +1034,8 @@ class OrderPayment(models.Model):
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else []
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order paid email could not be sent')
@@ -1454,6 +1507,10 @@ class OrderPosition(AbstractPosition):
self.pseudonymization_id = code
return
@property
def event(self):
return self.order.event
class CartPosition(AbstractPosition):
"""
@@ -1519,7 +1576,8 @@ class InvoiceAddress(models.Model):
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_parts = FallbackJSONField(default=dict)
street = models.TextField(verbose_name=_('Address'), blank=False)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
@@ -1537,8 +1595,25 @@ class InvoiceAddress(models.Model):
def save(self, **kwargs):
if self.order:
self.order.touch()
if self.name_parts:
self.name_cached = self.name
else:
self.name_cached = ""
super().save(**kwargs)
@property
def name(self):
if not self.name_parts:
return ""
if '_legacy' in self.name_parts:
return self.name_parts['_legacy']
if '_scheme' in self.name_parts:
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
else:
raise TypeError("Invalid name given.")
return scheme['concatenation'](self.name_parts).strip()
def cachedticket_name(instance, filename: str) -> str:
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)

View File

@@ -58,7 +58,7 @@ class Organizer(LoggedModel):
self.get_cache().clear()
return obj
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
Django's built-in cache backends, but puts you into an isolated environment for
@@ -82,6 +82,20 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self)
def allow_delete(self):
from . import Order, Invoice
return (
not Order.objects.filter(event__organizer=self).exists() and
not Invoice.objects.filter(event__organizer=self).exists() and
not self.devices.exists()
)
def delete_sub_objects(self):
for e in self.events.all():
e.delete_sub_objects()
e.delete()
self.teams.all().delete()
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)

View File

@@ -240,6 +240,8 @@ class Voucher(LoggedModel):
def clean_quota_needs_checking(data, old_instance, item_changed, creating):
# We only need to check for quota on vouchers that are now blocking quota and haven't
# before (or have blocked a different quota before)
if data.get('allow_ignore_quota', False):
return False
if data.get('block_quota', False):
is_valid = data.get('valid_until') is None or data.get('valid_until') >= now()
if not is_valid:

View File

@@ -225,7 +225,7 @@ def register_default_notification_types(sender, **kwargs):
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.changed',
'pretix.event.order.changed.*',
_('Order changed'),
_('Order {order.code} has been changed.')
),

View File

@@ -14,12 +14,14 @@ from django.http import HttpRequest
from django.template.loader import get_template
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries import Countries
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.forms import PlaceholderValidator
from pretix.base.models import (
CartPosition, Event, Order, OrderPayment, OrderRefund, Quota,
CartPosition, Event, InvoiceAddress, Order, OrderPayment, OrderRefund,
Quota,
)
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.settings import SettingsSandbox
@@ -28,7 +30,7 @@ from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.money import DecimalTextInput
from pretix.presale.views import get_cart_total
from pretix.presale.views.cart import get_or_create_cart_id
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__)
@@ -179,7 +181,7 @@ class BasePaymentProvider:
implementation.
"""
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
return OrderedDict([
d = OrderedDict([
('_enabled',
forms.BooleanField(
label=_('Enable payment method'),
@@ -250,7 +252,31 @@ class BasePaymentProvider:
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
required=False
)),
('_restricted_countries',
forms.MultipleChoiceField(
label=_('Restrict to countries'),
choices=Countries(),
help_text=_('Only allow choosing this payment provider for invoice addresses in the selected '
'countries. If you don\'t select any country, all countries are allowed. This is only '
'enabled if the invoice address is required.'),
widget=forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
required=False,
disabled=not self.event.settings.invoice_address_required
)),
])
d['_restricted_countries']._as_type = list
return d
def settings_form_clean(self, cleaned_data):
"""
Overriding this method allows you to inject custom validation into the settings form.
:param cleaned_data: Form data as per previous validations.
:return: Please return the modified cleaned_data
"""
return cleaned_data
def settings_content_render(self, request: HttpRequest) -> str:
"""
@@ -350,7 +376,8 @@ class BasePaymentProvider:
during checkout, not on retrying.
The default implementation checks for the _availability_date setting to be either unset or in the future
and for the _total_max and _total_min requirements to be met.
and for the _total_max and _total_min requirements to be met. It also checks the ``_restrict_countries``
setting.
:param total: The total value without the payment method fee, after taxes.
@@ -371,6 +398,26 @@ class BasePaymentProvider:
if self.settings._total_min is not None:
pricing = pricing and total >= Decimal(self.settings._total_min)
def get_invoice_address():
if not hasattr(request, '_checkout_flow_invoice_address'):
cs = cart_session(request)
iapk = cs.get('invoice_address')
if not iapk:
request._checkout_flow_invoice_address = InvoiceAddress()
else:
try:
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
except InvoiceAddress.DoesNotExist:
request._checkout_flow_invoice_address = InvoiceAddress()
return request._checkout_flow_invoice_address
if self.event.settings.invoice_address_required:
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
if restricted_countries:
ia = get_invoice_address()
if str(ia.country) not in restricted_countries:
return False
return timing and pricing
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
@@ -503,7 +550,8 @@ class BasePaymentProvider:
Will be called to check whether it is allowed to change the payment method of
an order to this one.
The default implementation checks for the _availability_date setting to be either unset or in the future.
The default implementation checks for the _availability_date setting to be either unset or in the future,
as well as for the _total_max, _total_min and _restricted_countries settings.
:param order: The order object
"""
@@ -514,6 +562,16 @@ class BasePaymentProvider:
if self.settings._total_min is not None and ps < Decimal(self.settings._total_min):
return False
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
if restricted_countries:
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
return True
else:
if str(ia.country) not in restricted_countries:
return False
return self._is_still_available(order=order)
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:

View File

@@ -26,6 +26,7 @@ from reportlab.platypus import Paragraph
from pretix.base.invoice import ThumbnailingImageReader
from pretix.base.models import Order, OrderPosition
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_text_variables
from pretix.base.templatetags.money import money_filter
from pretix.presale.style import get_fonts
@@ -147,12 +148,12 @@ DEFAULT_VARIABLES = OrderedDict((
"evaluate": lambda op, order, ev: str(ev.location).replace("\n", "<br/>\n")
}),
("invoice_name", {
"label": _("Invoice address: 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"),
"label": _("Invoice address company"),
"editor_sample": _("Sample company"),
"evaluate": lambda op, order, ev: order.invoice_address.company if getattr(order, 'invoice_address', None) else ''
}),
@@ -161,7 +162,10 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Addon 1\nAddon 2"),
"evaluate": lambda op, order, ev: "<br/>".join([
'{} - {}'.format(p.item, p.variation) if p.variation else str(p.item)
for p in op.addons.select_related('item', 'variation')
for p in (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation')
)
])
}),
("organizer", {
@@ -179,8 +183,28 @@ DEFAULT_VARIABLES = OrderedDict((
def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES)
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
for key, label, weight in scheme['fields']:
v['attendee_name_%s' % key] = {
'label': _("Attendee name: {part}").format(part=label),
'editor_sample': scheme['sample'][key],
'evaluate': lambda op, order, ev: op.attendee_name_parts.get(key, '')
}
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
v['attendee_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
for key, label, weight in scheme['fields']:
v['invoice_name_%s' % key] = {
'label': _("Invoice address name: {part}").format(part=label),
'editor_sample': scheme['sample'][key],
"evaluate": lambda op, order, ev: order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
}
for recv, res in layout_text_variables.send(sender=event):
v.update(res)
return v
@@ -191,7 +215,7 @@ class Renderer:
self.background_file = background_file
self.variables = get_variables(event)
if self.background_file:
self.bg_pdf = PdfFileReader(BytesIO(self.background_file.read()))
self.bg_pdf = PdfFileReader(BytesIO(self.background_file.read()), strict=False)
else:
self.bg_pdf = None
@@ -213,6 +237,8 @@ class Renderer:
def _draw_poweredby(self, canvas: Canvas, op: OrderPosition, o: dict):
content = o.get('content', 'dark')
if content not in ('dark', 'white'):
content = 'dark'
img = finders.find('pretixpresale/pdf/powered_by_pretix_{}.png'.format(content))
ir = ThumbnailingImageReader(img)

View File

@@ -570,6 +570,7 @@ class CartManager:
if op.position.expires > self.now_dt:
for q in op.position.quotas:
quotas_ok[q] += 1
op.position.addons.all().delete()
op.position.delete()
elif isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):

View File

@@ -59,7 +59,8 @@ def _save_answers(op, answers, given_answers):
@transaction.atomic
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True):
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None):
"""
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.
@@ -133,7 +134,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'forced': op.order.status != Order.STATUS_PAID,
'datetime': dt,
'list': clist.pk
})
}, user=user, auth=auth)
else:
if not force:
raise CheckInError(
@@ -147,4 +148,4 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'forced': force,
'datetime': dt,
'list': clist.pk
})
}, user=user, auth=auth)

View File

@@ -14,6 +14,7 @@ from django.dispatch import receiver
from django.utils import timezone
from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _
from django_countries.fields import Country
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
@@ -40,6 +41,12 @@ def build_invoice(invoice: Invoice) -> Invoice:
with language(invoice.locale):
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
@@ -66,8 +73,16 @@ def build_invoice(invoice: Invoice) -> Invoice:
country=ia.country.name if ia.country else ia.country_old
).strip()
invoice.internal_reference = ia.internal_reference
invoice.invoice_to_company = ia.company
invoice.invoice_to_name = ia.name
invoice.invoice_to_street = ia.street
invoice.invoice_to_zipcode = ia.zipcode
invoice.invoice_to_city = ia.city
invoice.invoice_to_country = ia.country
if ia.vat_id:
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
invoice.invoice_to_vat_id = ia.vat_id
cc = str(ia.country)
@@ -138,6 +153,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
"rests with the service recipient."
)
invoice.reverse_charge = True
invoice.save()
offset = len(positions)
@@ -200,10 +216,10 @@ def regenerate_invoice(invoice: Invoice):
def generate_invoice(order: Order, trigger_pdf=True):
locale = order.event.settings.get('invoice_language')
locale = order.event.settings.get('invoice_language', order.event.settings.locale)
if locale:
if locale == '__user__':
locale = order.locale
locale = order.locale or order.event.settings.locale
invoice = Invoice(
order=order,
@@ -267,6 +283,12 @@ def build_preview_invoice_pdf(event):
date=timezone.now().date(), locale=locale, organizer=event.organizer
)
invoice.invoice_from = event.settings.get('invoice_address_from')
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
introductory = event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = event.settings.get('invoice_additional_text', as_type=LazyI18nString)
@@ -277,7 +299,15 @@ def build_preview_invoice_pdf(event):
invoice.additional_text = str(additional).replace('\n', '<br />')
invoice.footer_text = str(footer)
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
invoice.invoice_to = _("John Doe\n214th Example Street\n012345 Somecity")
invoice.invoice_to_name = _("John Doe")
invoice.invoice_to_street = _("214th Example Street")
invoice.invoice_to_zipcode = _("012345")
invoice.invoice_to_city = _('Sample city')
invoice.invoice_to_country = Country('DE')
invoice.invoice_to = '{}\n{}\n{} {}'.format(
invoice.invoice_to_name, invoice.invoice_to_street,
invoice.invoice_to_zipcode, invoice.invoice_to_city
)
invoice.file = None
invoice.save()
invoice.lines.all().delete()

View File

@@ -14,6 +14,7 @@ from pretix.base.email import ClassicMailRenderer
from pretix.base.i18n import language
from pretix.base.models import Event, Invoice, InvoiceAddress, Order
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter
from pretix.celery_app import app
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -35,7 +36,8 @@ class SendMailException(Exception):
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None):
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None,
attach_tickets=False):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -65,6 +67,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
:param invoices: A list of invoices to attach to this email.
:param attach_tickets: Whether to attach tickets to this email, if they are available to download.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
that the email has been sent, just that it has been queued by the email backend.
"""
@@ -153,7 +157,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
event=event.id if event else None,
headers=headers,
invoices=[i.pk for i in invoices] if invoices else [],
order=order.pk if order else None
order=order.pk if order else None,
attach_tickets=attach_tickets
)
if invoices:
@@ -168,7 +173,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
@app.task
def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
order: int=None) -> bool:
order: int=None, attach_tickets=False) -> bool:
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
email.attach_alternative(html, "text/html")
@@ -176,11 +181,16 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices:
if inv.file:
try:
email.attach(
'{}.pdf'.format(inv.number),
inv.file.file.read(),
'application/pdf'
)
except:
logger.exception('Could not attach invoice to email')
pass
if event:
event = Event.objects.get(id=event)
backend = event.get_mail_backend()
@@ -193,6 +203,18 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
order = event.orders.get(pk=order)
except Order.DoesNotExist:
order = None
else:
if attach_tickets:
for name, ct in get_tickets_for_order(order):
try:
email.attach(
name,
ct.file.read(),
ct.type
)
except:
pass
email = email_filter.send_chained(event, 'message', message=email, order=order)
try:

View File

@@ -17,9 +17,15 @@ def notify(logentry_id: int):
if not logentry.event:
return # Ignore, we only have event-related notifications right now
types = get_all_notification_types(logentry.event)
notification_type = types.get(logentry.action_type)
notification_type = None
typepath = logentry.action_type
while not notification_type and '.' in typepath:
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
if not notification_type:
return # Ignore, e.g. plugin not active for this event
return # No suitable plugin
# All users that have the permission to get the notification
users = logentry.event.get_users_with_permission(
@@ -33,7 +39,7 @@ def notify(logentry_id: int):
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=logentry.action_type,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
@@ -41,7 +47,7 @@ def notify(logentry_id: int):
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event__isnull=True,
action_type=logentry.action_type,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
@@ -49,20 +55,20 @@ def notify(logentry_id: int):
for um, enabled in notify_specific.items():
user, method = um
if enabled:
send_notification.apply_async(args=(logentry_id, user.pk, method))
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
for um, enabled in notify_global.items():
user, method = um
if enabled and um not in notify_specific:
send_notification.apply_async(args=(logentry_id, user.pk, method))
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
@app.task(base=ProfiledTask)
def send_notification(logentry_id: int, user_id: int, method: str):
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
logentry = LogEntry.all.get(id=logentry_id)
user = User.objects.get(id=user_id)
types = get_all_notification_types(logentry.event)
notification_type = types.get(logentry.action_type)
notification_type = types.get(action_type)
if not notification_type:
return # Ignore, e.g. plugin not active for this event

View File

@@ -21,7 +21,7 @@ from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
)
from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Order, OrderPayment,
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
OrderPosition, Quota, User, Voucher,
)
from pretix.base.models.event import SubEvent
@@ -93,7 +93,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
raise OrderError(_('The new expiry date needs to be in the future.'))
if order.status == Order.STATUS_PENDING:
order.expires = new_date
order.save()
order.save(update_fields=['expires'])
order.log_action(
'pretix.event.order.expirychanged',
user=user,
@@ -109,7 +109,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
if is_available is True or force is True:
order.expires = new_date
order.status = Order.STATUS_PENDING
order.save()
order.save(update_fields=['expires', 'status'])
order.log_action(
'pretix.event.order.expirychanged',
user=user,
@@ -136,7 +136,7 @@ def mark_order_refunded(order, user=None, auth=None, api_token=None):
user = User.objects.get(pk=user)
with order.event.lock():
order.status = Order.STATUS_REFUNDED
order.save()
order.save(update_fields=['status'])
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
i = order.invoices.filter(is_cancellation=False).last()
@@ -159,7 +159,7 @@ def mark_order_expired(order, user=None, auth=None):
user = User.objects.get(pk=user)
with order.event.lock():
order.status = Order.STATUS_EXPIRED
order.save()
order.save(update_fields=['status'])
order.log_action('pretix.event.order.expired', user=user, auth=auth)
i = order.invoices.filter(is_cancellation=False).last()
@@ -181,7 +181,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None):
order.require_approval = False
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
order.save()
order.save(update_fields=['require_approval', 'expires'])
order.log_action('pretix.event.order.approved', user=user, auth=auth)
if order.total == Decimal('0.00'):
@@ -199,7 +199,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None):
invoice = order.invoices.last() # Might be generated by plugin already
if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
if not invoice:
generate_invoice(
invoice = generate_invoice(
order,
trigger_pdf=not order.event.settings.invoice_email_attachment or not order.email
)
@@ -237,7 +237,8 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None):
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_approved', user
'pretix.event.order.email.order_approved', user,
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
)
except SendMailException:
logger.exception('Order approved email could not be sent')
@@ -257,7 +258,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
with order.event.lock():
order.status = Order.STATUS_CANCELED
order.save()
order.save(update_fields=['status'])
order.log_action('pretix.event.order.denied', user=user, auth=auth, data={
'comment': comment
@@ -306,7 +307,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
@transaction.atomic
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None):
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None):
"""
Mark this order as canceled
:param order: The order to change
@@ -318,15 +319,17 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_
user = User.objects.get(pk=user)
if isinstance(api_token, int):
api_token = TeamAPIToken.objects.get(pk=api_token)
if isinstance(device, int):
device = Device.objects.get(pk=device)
if isinstance(oauth_application, int):
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
with order.event.lock():
if not order.cancel_allowed():
raise OrderError(_('You cannot cancel this order.'))
order.status = Order.STATUS_CANCELED
order.save()
order.save(update_fields=['status'])
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application)
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
@@ -630,7 +633,8 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else []
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order received email could not be sent')
@@ -657,7 +661,12 @@ def send_expiry_warnings(sender, **kwargs):
eventcache = {}
today = now().replace(hour=0, minute=0, second=0)
for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).select_related('event'):
for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).only('pk'):
with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
# Race condition
continue
eventsettings = eventcache.get(o.event.pk, None)
if eventsettings is None:
eventsettings = o.event.settings
@@ -668,7 +677,7 @@ def send_expiry_warnings(sender, **kwargs):
if days and (o.expires - today).days <= days:
with language(o.locale):
o.expiry_reminder_sent = True
o.save()
o.save(update_fields=['expiry_reminder_sent'])
try:
invoice_name = o.invoice_address.name
invoice_company = o.invoice_address.company
@@ -705,6 +714,7 @@ def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
for e in Event.objects.filter(date_from__gte=today):
days = e.settings.get('mail_days_download_reminder', as_type=int)
if days is None:
continue
@@ -713,13 +723,18 @@ def send_download_reminders(sender, **kwargs):
if now() < reminder_date:
continue
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False):
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False).only('pk'):
with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
continue
with language(o.locale):
o.download_reminder_sent = True
o.save()
o.save(update_fields=['download_reminder_sent'])
email_template = e.settings.mail_text_download_reminder
email_context = {
'event': o.event.name,
@@ -732,7 +747,8 @@ def send_download_reminders(sender, **kwargs):
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent'
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
except SendMailException:
logger.exception('Reminder email could not be sent')
@@ -906,6 +922,7 @@ class OrderChangeManager:
def _check_paid_price_change(self):
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
if self.order.pending_sum > Decimal('0.00'):
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
now(),
@@ -1165,7 +1182,7 @@ class OrderChangeManager:
fee.save()
if not self.open_payment.fee:
self.open_payment.fee = fee
self.open_payment.save()
self.open_payment.save(update_fields=['fee'])
elif fee:
fee.delete()
@@ -1298,10 +1315,11 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None):
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None):
try:
try:
return _cancel_order(order, user, send_mail, api_token, oauth_application)
return _cancel_order(order, user, send_mail, api_token, device, oauth_application)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):

View File

@@ -1,3 +1,4 @@
import logging
import os
from datetime import timedelta
@@ -11,10 +12,13 @@ from pretix.base.models import (
OrderPosition,
)
from pretix.base.services.tasks import ProfiledTask
from pretix.base.signals import register_ticket_outputs
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import allow_ticket_download, register_ticket_outputs
from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction
logger = logging.getLogger(__name__)
@app.task(base=ProfiledTask)
def generate(order_position: str, provider: str):
@@ -84,11 +88,13 @@ def preview(event: int, provider: str):
locale=event.settings.locale,
expires=now(), code="PREVIEW1234", total=119)
p = order.positions.create(item=item, attendee_name=_("John Doe"), price=item.default_price)
order.positions.create(item=item2, attendee_name=_("John Doe"), price=item.default_price, addon_to=p)
order.positions.create(item=item2, attendee_name=_("John Doe"), price=item.default_price, addon_to=p)
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
sample = {k: str(v) for k, v in scheme['sample'].items()}
p = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p)
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p)
InvoiceAddress.objects.create(order=order, name=_("John Doe"), company=_("Sample company"))
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
responses = register_ticket_outputs.send(event)
for receiver, response in responses:
@@ -97,7 +103,8 @@ def preview(event: int, provider: str):
return prov.generate(p)
def get_cachedticket_for_position(pos, identifier):
def get_cachedticket_for_position(pos, identifier, generate_async=True):
apply_method = 'apply_async' if generate_async else 'apply'
try:
ct = CachedTicket.objects.filter(
order_position=pos, provider=identifier
@@ -109,15 +116,20 @@ def get_cachedticket_for_position(pos, identifier):
ct = CachedTicket.objects.create(
order_position=pos, provider=identifier,
extension='', type='', file=None)
generate.apply_async(args=(pos.id, identifier))
getattr(generate, apply_method)(args=(pos.id, identifier))
if not generate_async:
ct.refresh_from_db()
if not ct.file:
if now() - ct.created > timedelta(minutes=5):
generate.apply_async(args=(pos.id, identifier))
getattr(generate, apply_method)(args=(pos.id, identifier))
if not generate_async:
ct.refresh_from_db()
return ct
def get_cachedticket_for_order(order, identifier):
def get_cachedticket_for_order(order, identifier, generate_async=True):
apply_method = 'apply_async' if generate_async else 'apply'
try:
ct = CachedCombinedTicket.objects.filter(
order=order, provider=identifier
@@ -129,9 +141,63 @@ def get_cachedticket_for_order(order, identifier):
ct = CachedCombinedTicket.objects.create(
order=order, provider=identifier,
extension='', type='', file=None)
generate_order.apply_async(args=(order.id, identifier))
getattr(generate_order, apply_method)(args=(order.id, identifier))
if not generate_async:
ct.refresh_from_db()
if not ct.file:
if now() - ct.created > timedelta(minutes=5):
generate_order.apply_async(args=(order.id, identifier))
getattr(generate_order, apply_method)(args=(order.id, identifier))
if not generate_async:
ct.refresh_from_db()
return ct
def get_tickets_for_order(order):
can_download = all([r for rr, r in allow_ticket_download.send(order.event, order=order)])
if not can_download:
return []
if not order.ticket_download_available:
return []
providers = [
response(order.event)
for receiver, response
in register_ticket_outputs.send(order.event)
]
tickets = []
for p in providers:
if not p.is_enabled:
continue
if p.multi_download_enabled:
try:
ct = get_cachedticket_for_order(order, p.identifier, generate_async=False)
tickets.append((
"{}-{}-{}{}".format(
order.event.slug.upper(), order.code, ct.provider, ct.extension,
),
ct
))
except:
logger.exception('Failed to generate ticket.')
else:
for pos in order.positions.all():
if pos.addon_to and not order.event.settings.ticket_download_addons:
continue
if not pos.item.admission and not order.event.settings.ticket_download_nonadm:
continue
try:
ct = get_cachedticket_for_position(pos, p.identifier, generate_async=False)
tickets.append((
"{}-{}-{}-{}{}".format(
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
),
ct
))
except:
logger.exception('Failed to generate ticket.')
return tickets

View File

@@ -74,5 +74,5 @@ def process_waitinglist(sender, **kwargs):
live=True
).prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer')
for e in qs:
if e.settings.waiting_list_enabled and e.settings.waiting_list_auto and e.presale_is_running:
if e.settings.waiting_list_auto and e.presale_is_running:
assign_automatically.apply_async(args=(e.pk,))

View File

@@ -1,11 +1,14 @@
import json
from collections import OrderedDict
from datetime import datetime
from typing import Any
from django.conf import settings
from django.core.files import File
from django.db.models import Model
from django.utils.translation import ugettext_noop
from django.utils.translation import (
pgettext_lazy, ugettext_lazy as _, ugettext_noop,
)
from hierarkey.models import GlobalSettingsBase, Hierarkey
from i18nfield.strings import LazyI18nString
@@ -556,7 +559,154 @@ Your {event} team"""))
'default': 'date_ascending',
'type': str
},
'name_scheme': {
'default': 'full',
'type': str
}
}
PERSON_NAME_SCHEMES = OrderedDict([
('given_family', {
'fields': (
('given_name', _('Given name'), 1),
('family_name', _('Family name'), 1),
),
'concatenation': lambda d: ' '.join(str(p) for p in [d.get('given_name', ''), d.get('family_name', '')] if p),
'sample': {
'given_name': pgettext_lazy('person_name_sample', 'John'),
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'given_family',
},
}),
('title_given_family', {
'fields': (
('title', pgettext_lazy('person_name', 'Title'), 1),
('given_name', _('Given name'), 2),
('family_name', _('Family name'), 2),
),
'concatenation': lambda d: ' '.join(
str(p) for p in [d.get('title', ''), d.get('given_name', ''), d.get('family_name', '')] if p
),
'sample': {
'title': pgettext_lazy('person_name_sample', 'Dr'),
'given_name': pgettext_lazy('person_name_sample', 'John'),
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'title_given_family',
},
}),
('given_middle_family', {
'fields': (
('given_name', _('First name'), 2),
('middle_name', _('Middle name'), 1),
('family_name', _('Family name'), 2),
),
'concatenation': lambda d: ' '.join(
str(p) for p in [d.get('given_name', ''), d.get('middle_name', ''), d.get('family_name', '')] if p
),
'sample': {
'given_name': pgettext_lazy('person_name_sample', 'John'),
'middle_name': 'M',
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'given_middle_family',
},
}),
('title_given_middle_family', {
'fields': (
('title', pgettext_lazy('person_name', 'Title'), 1),
('given_name', _('First name'), 2),
('middle_name', _('Middle name'), 1),
('family_name', _('Family name'), 1),
),
'concatenation': lambda d: ' '.join(
str(p) for p in [d.get('title', ''), d.get('given_name'), d.get('middle_name'), d.get('family_name')] if p
),
'sample': {
'title': pgettext_lazy('person_name_sample', 'Dr'),
'given_name': pgettext_lazy('person_name_sample', 'John'),
'middle_name': 'M',
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'title_given_middle_family',
},
}),
('family_given', {
'fields': (
('family_name', _('Family name'), 1),
('given_name', _('Given name'), 1),
),
'concatenation': lambda d: ' '.join(
str(p) for p in [d.get('family_name', ''), d.get('given_name', '')] if p
),
'sample': {
'given_name': pgettext_lazy('person_name_sample', 'John'),
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'family_given',
},
}),
('family_nospace_given', {
'fields': (
('given_name', _('Given name'), 1),
('family_name', _('Family name'), 1),
),
'concatenation': lambda d: ''.join(
str(p) for p in [d.get('family_name', ''), d.get('given_name', '')] if p
),
'sample': {
'given_name': '泽东',
'family_name': '',
'_scheme': 'family_nospace_given',
},
}),
('family_comma_given', {
'fields': (
('given_name', _('Given name'), 1),
('family_name', _('Family name'), 1),
),
'concatenation': lambda d: (
str(d.get('family_name', '')) +
str((', ' if d.get('family_name') and d.get('given_name') else '')) +
str(d.get('given_name', ''))
),
'sample': {
'given_name': pgettext_lazy('person_name_sample', 'John'),
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'family_comma_given',
},
}),
('full', {
'fields': (
('full_name', _('Name'), 1),
),
'concatenation': lambda d: str(d.get('full_name', '')),
'sample': {
'full_name': pgettext_lazy('person_name_sample', 'John Doe'),
'_scheme': 'full',
},
}),
('calling_full', {
'fields': (
('calling_name', _('Calling name'), 1),
('full_name', _('Full name'), 2),
),
'concatenation': lambda d: str(d.get('full_name', '')),
'sample': {
'full_name': pgettext_lazy('person_name_sample', 'John Doe'),
'calling_name': pgettext_lazy('person_name_sample', 'John'),
'_scheme': 'calling_full',
},
}),
('full_transcription', {
'fields': (
('full_name', _('Full name'), 1),
('latin_transcription', _('Latin transcription'), 2),
),
'concatenation': lambda d: str(d.get('full_name', '')),
'sample': {
'full_name': '庄司',
'latin_transcription': 'Shōji',
'_scheme': 'full_transcription',
},
}),
])
settings_hierarkey = Hierarkey(attribute_name='settings')

View File

@@ -3,7 +3,7 @@ from datetime import timedelta
from typing import List, Tuple
from django.db import transaction
from django.db.models import Max
from django.db.models import Max, Q
from django.db.models.functions import Greatest
from django.dispatch import receiver
from django.utils.timezone import now
@@ -202,12 +202,20 @@ class AttendeeNameShredder(BaseDataShredder):
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)
for op in OrderPosition.objects.filter(
order__event=self.event
).filter(
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__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)
OrderPosition.objects.filter(
order__event=self.event
).filter(
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
).update(attendee_name_cached=None, attendee_name_parts={'_shredded': True})
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified").exclude(data=""):
d = le.parsed_data
@@ -215,6 +223,10 @@ class AttendeeNameShredder(BaseDataShredder):
for i, row in enumerate(d['data']):
if 'attendee_name' in row:
d['data'][i]['attendee_name'] = ''
if 'attendee_name_parts' in row:
d['data'][i]['attendee_name_parts'] = {
'_legacy': ''
}
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])

View File

@@ -16,6 +16,6 @@
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
<script src="{% static "pretixbase/js/errors.js" %}"></script>
</body>
</html>

View File

@@ -80,8 +80,8 @@ class BaseQuestionsViewMixin:
# This form was correctly filled, so we store the data as
# answers to the questions / in the CartPosition object
for k, v in form.cleaned_data.items():
if k == 'attendee_name':
form.pos.attendee_name = v if v != '' else None
if k == 'attendee_name_parts':
form.pos.attendee_name_parts = v if v else None
form.pos.save()
elif k == 'attendee_email':
form.pos.attendee_email = v if v != '' else None

View File

@@ -1,7 +1,10 @@
import datetime
import os
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.forms.utils import from_current_timezone
from django.utils.html import conditional_escape
from django.utils.translation import ugettext_lazy as _
@@ -168,3 +171,17 @@ class SingleLanguageWidget(forms.Select):
def optgroups(self, name, value, attrs=None):
self.modify()
return super().optgroups(name, value, attrs)
class SplitDateTimeField(forms.SplitDateTimeField):
def compress(self, data_list):
# Differs from the default implementation: If only a time is given and no date, we consider the field empty
if data_list:
if data_list[0] in self.empty_values:
return None
if data_list[1] in self.empty_values:
raise ValidationError(self.error_messages['invalid_date'], code='invalid_date')
result = datetime.datetime.combine(*data_list)
return from_current_timezone(result)
return None

View File

@@ -9,7 +9,7 @@ from django.utils.timezone import get_current_timezone_name
from django.utils.translation import (
pgettext, pgettext_lazy, ugettext_lazy as _,
)
from django_countries import Countries
from django_countries import Countries, countries
from django_countries.fields import LazyTypedChoiceField
from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
@@ -20,9 +20,10 @@ from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.models import Event, Organizer, TaxRule
from pretix.base.models.event import EventMetaValue, SubEvent
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.control.forms import (
ExtFileField, MultipleLanguagesWidget, SingleLanguageWidget, SlugWidget,
SplitDateTimePickerWidget,
SplitDateTimeField, SplitDateTimePickerWidget,
)
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.plugins.banktransfer.payment import BankTransfer
@@ -95,10 +96,10 @@ class EventWizardBasicsForm(I18nModelForm):
'location',
]
field_classes = {
'date_from': forms.SplitDateTimeField,
'date_to': forms.SplitDateTimeField,
'presale_start': forms.SplitDateTimeField,
'presale_end': forms.SplitDateTimeField,
'date_from': SplitDateTimeField,
'date_to': SplitDateTimeField,
'presale_start': SplitDateTimeField,
'presale_end': SplitDateTimeField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(),
@@ -202,17 +203,22 @@ class EventMetaValueForm(forms.ModelForm):
class EventUpdateForm(I18nModelForm):
def clean_slug(self):
return self.instance.slug
def __init__(self, *args, **kwargs):
self.change_slug = kwargs.pop('change_slug', False)
super().__init__(*args, **kwargs)
if not self.change_slug:
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
self.fields['location'].widget.attrs['rows'] = '3'
self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center\nHeidelberg, Germany'
)
def clean_slug(self):
if self.change_slug:
return self.cleaned_data['slug']
return self.instance.slug
class Meta:
model = Event
localized_fields = '__all__'
@@ -229,11 +235,11 @@ class EventUpdateForm(I18nModelForm):
'location',
]
field_classes = {
'date_from': forms.SplitDateTimeField,
'date_to': forms.SplitDateTimeField,
'date_admission': forms.SplitDateTimeField,
'presale_start': forms.SplitDateTimeField,
'presale_end': forms.SplitDateTimeField,
'date_from': SplitDateTimeField,
'date_to': SplitDateTimeField,
'date_admission': SplitDateTimeField,
'presale_start': SplitDateTimeField,
'presale_end': SplitDateTimeField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(),
@@ -311,15 +317,16 @@ class EventSettingsForm(SettingsForm):
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
"number of hours until it expires and can be re-assigned to the next person on the list."),
required=False,
widget=forms.NumberInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
widget=forms.NumberInput(),
)
waiting_list_auto = forms.BooleanField(
label=_("Automatic waiting list assignments"),
help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person "
"on the waiting list for that product. If this is not active, mails will not be send automatically "
"but you can send them manually via the control panel."),
"but you can send them manually via the control panel. If you disable the waiting list but keep "
"this option enabled, tickets will still be sent out."),
required=False,
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
widget=forms.CheckboxInput(),
)
attendee_names_asked = forms.BooleanField(
label=_("Ask for attendee names"),
@@ -332,6 +339,12 @@ class EventSettingsForm(SettingsForm):
required=False,
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
)
name_scheme = forms.ChoiceField(
label=_("Name format"),
help_text=_("This defines how pretix will ask for human names. Changing this after you already received "
"orders might lead to unexpected behaviour when sorting or changing names."),
required=True,
)
attendee_emails_asked = forms.BooleanField(
label=_("Ask for email addresses per ticket"),
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
@@ -413,6 +426,13 @@ class EventSettingsForm(SettingsForm):
'e.g. I hereby confirm that I have read and agree with the event organizer\'s terms of service '
'and agree with them.'
)
self.fields['name_scheme'].choices = (
(k, _('Ask for {fields}, display like {example}').format(
fields=' + '.join(str(vv[1]) for vv in v['fields']),
example=v['concatenation'](v['sample'])
))
for k, v in PERSON_NAME_SCHEMES.items()
)
class PaymentSettingsForm(SettingsForm):
@@ -483,6 +503,7 @@ class ProviderForm(SettingsForm):
def __init__(self, *args, **kwargs):
self.settingspref = kwargs.pop('settingspref')
self.provider = kwargs.pop('provider', None)
super().__init__(*args, **kwargs)
def prepare_fields(self):
@@ -497,6 +518,9 @@ class ProviderForm(SettingsForm):
elif isinstance(v, (RelativeDateTimeField, RelativeDateField)):
v.set_event(self.obj)
if hasattr(v, '_as_type'):
self.initial[k] = self.obj.settings.get(k, as_type=v._as_type)
def clean(self):
cleaned_data = super().clean()
enabled = cleaned_data.get(self.settingspref + '_enabled')
@@ -506,9 +530,15 @@ class ProviderForm(SettingsForm):
val = cleaned_data.get(k)
if v._required and not val:
self.add_error(k, _('This field is required.'))
if self.provider:
cleaned_data = self.provider.settings_form_clean(cleaned_data)
return cleaned_data
class InvoiceSettingsForm(SettingsForm):
allcountries = list(countries)
allcountries.insert(0, ('', _('Select country')))
invoice_address_asked = forms.BooleanField(
label=_("Ask for invoice address"),
required=False
@@ -559,9 +589,10 @@ class InvoiceSettingsForm(SettingsForm):
invoice_generate = forms.ChoiceField(
label=_("Generate invoices"),
required=False,
widget=forms.RadioSelect,
choices=(
('False', _('No')),
('admin', _('Manually in admin panel')),
('False', _('Do not generate invoices')),
('admin', _('Only manually in admin panel')),
('user', _('Automatically on user request')),
('True', _('Automatically for all created orders')),
('paid', _('Automatically on payment')),
@@ -585,19 +616,46 @@ class InvoiceSettingsForm(SettingsForm):
required=True,
choices=[]
)
invoice_address_from_name = forms.CharField(
label=_("Company name"),
required=False,
)
invoice_address_from = forms.CharField(
label=_("Address line"),
widget=forms.Textarea(attrs={
'rows': 5,
'rows': 2,
'placeholder': _(
'Sample Event Company\n'
'Albert Einstein Road 52\n'
'12345 Samplecity'
'Albert Einstein Road 52'
)
}),
required=False,
label=_("Your address"),
help_text=_("Will be printed as the sender on invoices. Be sure to include relevant details required in "
"your jurisdiction.")
)
invoice_address_from_zipcode = forms.CharField(
widget=forms.TextInput(attrs={
'placeholder': '12345'
}),
required=False,
label=_("ZIP code"),
)
invoice_address_from_city = forms.CharField(
widget=forms.TextInput(attrs={
'placeholder': _('Random City')
}),
required=False,
label=_("City"),
)
invoice_address_from_country = forms.ChoiceField(
choices=allcountries,
required=False,
label=_("Country"),
)
invoice_address_from_tax_id = forms.CharField(
required=False,
label=_("Domestic tax ID"),
)
invoice_address_from_vat_id = forms.CharField(
required=False,
label=_("EU VAT ID"),
)
invoice_introductory_text = I18nFormField(
widget=I18nTextarea,
@@ -916,6 +974,10 @@ class DisplaySettingsForm(SettingsForm):
('name_descending', _('Name (descending)')),
], # When adding a new ordering, remember to also define it in the event model
)
meta_noindex = forms.BooleanField(
label=_('Ask search engines not to index the ticket shop'),
required=False
)
def __init__(self, *args, **kwargs):
event = kwargs['obj']
@@ -943,12 +1005,14 @@ class TicketSettingsForm(SettingsForm):
ticket_download_addons = forms.BooleanField(
label=_("Offer to download tickets separately for add-on products"),
required=False,
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
)
ticket_download_nonadm = forms.BooleanField(
label=_("Generate tickets for non-admission products"),
required=False,
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
)
ticket_download_pending = forms.BooleanField(
label=_("Offer to download tickets even before an order is paid"),
required=False,
)
def prepare_fields(self):
@@ -1174,7 +1238,13 @@ class QuickSetupForm(I18nForm):
"bank statements to process the payments within pretix, or mark them as paid manually."),
required=False
)
payment_banktransfer_bank_details = BankTransfer.form_field(required=False)
btf = BankTransfer.form_fields()
payment_banktransfer_bank_details_type = btf['bank_details_type']
payment_banktransfer_bank_details_sepa_name = btf['bank_details_sepa_name']
payment_banktransfer_bank_details_sepa_iban = btf['bank_details_sepa_iban']
payment_banktransfer_bank_details_sepa_bic = btf['bank_details_sepa_bic']
payment_banktransfer_bank_details_sepa_bank = btf['bank_details_sepa_bank']
payment_banktransfer_bank_details = btf['bank_details']
def __init__(self, *args, **kwargs):
self.obj = kwargs.pop('event', None)
@@ -1184,6 +1254,16 @@ class QuickSetupForm(I18nForm):
if not self.obj.settings.payment_stripe_connect_client_id:
del self.fields['payment_stripe__enabled']
self.fields['payment_banktransfer_bank_details'].required = False
for f in self.fields.values():
if 'data-required-if' in f.widget.attrs:
del f.widget.attrs['data-required-if']
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get('payment_banktransfer__enabled'):
provider = BankTransfer(self.obj)
cleaned_data = provider.settings_form_clean(cleaned_data)
return cleaned_data
class QuickSetupProductForm(I18nForm):

View File

@@ -129,7 +129,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(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u)
)
).values('id')
@@ -137,7 +137,7 @@ class OrderFilterForm(FilterForm):
qs = qs.annotate(has_pos=Exists(matching_positions)).filter(
code
| Q(email__icontains=u)
| Q(invoice_address__name__icontains=u)
| Q(invoice_address__name_cached__icontains=u)
| Q(invoice_address__company__icontains=u)
| Q(pk__in=matching_invoices)
| Q(comment__icontains=u)
@@ -568,9 +568,9 @@ class CheckInFilterForm(FilterForm):
'item': ('item__name', 'variation__value', 'order__code'),
'-item': ('-item__name', '-variation__value', '-order__code'),
'name': {'_order': F('display_name').asc(nulls_first=True),
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')},
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
'-name': {'_order': F('display_name').desc(nulls_last=True),
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')},
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
}
user = forms.CharField(
@@ -615,10 +615,10 @@ class CheckInFilterForm(FilterForm):
Q(order__code__istartswith=u)
| Q(secret__istartswith=u)
| Q(order__email__icontains=u)
| Q(attendee_name__icontains=u)
| Q(attendee_name_cached__icontains=u)
| Q(attendee_email__icontains=u)
| Q(voucher__code__istartswith=u)
| Q(order__invoice_address__name__icontains=u)
| Q(order__invoice_address__name_cached__icontains=u)
| Q(order__invoice_address__company__icontains=u)
)
@@ -796,6 +796,9 @@ class VoucherFilterForm(FilterForm):
if fdata.get('tag'):
s = fdata.get('tag').strip()
if s == '<>':
qs = qs.filter(Q(tag__isnull=True) | Q(tag=''))
else:
qs = qs.filter(tag__icontains=s)
if fdata.get('qm'):

View File

@@ -14,7 +14,7 @@ from pretix.base.models import (
)
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 import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2
from pretix.helpers.models import modelcopy
from pretix.helpers.money import change_decimal_field
@@ -330,8 +330,8 @@ class ItemUpdateForm(I18nModelForm):
'original_price'
]
field_classes = {
'available_from': forms.SplitDateTimeField,
'available_until': forms.SplitDateTimeField,
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
}
widgets = {
'available_from': SplitDateTimePickerWidget(),

View File

@@ -2,11 +2,14 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils.translation import ugettext_lazy as _
from django.utils.safestring import mark_safe
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.models import Organizer, Team
from pretix.base.models import Device, Organizer, Team
from pretix.control.forms import ExtFileField, MultipleLanguagesWidget
from pretix.multidomain.models import KnownDomain
from pretix.presale.style import get_fonts
@@ -31,10 +34,34 @@ class OrganizerForm(I18nModelForm):
return slug
class OrganizerDeleteForm(forms.Form):
error_messages = {
'slug_wrong': _("The slug you entered was not correct."),
}
slug = forms.CharField(
max_length=255,
label=_("Event slug"),
)
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
def clean_slug(self):
slug = self.cleaned_data.get('slug')
if slug != self.organizer.slug:
raise forms.ValidationError(
self.error_messages['slug_wrong'],
code='slug_wrong',
)
return slug
class OrganizerUpdateForm(OrganizerForm):
def __init__(self, *args, **kwargs):
self.domain = kwargs.pop('domain', False)
self.change_slug = kwargs.pop('change_slug', False)
kwargs.setdefault('initial', {})
self.instance = kwargs['instance']
if self.domain and self.instance:
@@ -43,6 +70,7 @@ class OrganizerUpdateForm(OrganizerForm):
kwargs['initial'].setdefault('domain', initial_domain.domainname)
super().__init__(*args, **kwargs)
if not self.change_slug:
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
if self.domain:
self.fields['domain'] = forms.CharField(
@@ -53,6 +81,8 @@ class OrganizerUpdateForm(OrganizerForm):
)
def clean_slug(self):
if self.change_slug:
return self.cleaned_data['slug']
return self.instance.slug
def save(self, commit=True):
@@ -115,6 +145,23 @@ class TeamForm(forms.ModelForm):
return data
class DeviceForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields['limit_events'].queryset = organizer.events.all()
class Meta:
model = Device
fields = ['name', 'all_events', 'limit_events']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events'
}),
}
class OrganizerSettingsForm(SettingsForm):
organizer_info_text = I18nFormField(
@@ -178,3 +225,32 @@ class OrganizerDisplaySettingsForm(SettingsForm):
self.fields['primary_font'].choices += [
(a, a) for a in get_fonts()
]
class WebHookForm(forms.ModelForm):
events = forms.MultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
label=pgettext_lazy('webhooks', 'Event types')
)
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields['limit_events'].queryset = organizer.events.all()
self.fields['events'].choices = [
(
a.action_type,
mark_safe('{} <code>{}</code>'.format(a.verbose_name, a.action_type))
) for a in get_all_webhook_events().values()
]
if self.instance:
self.fields['events'].initial = list(self.instance.listeners.values_list('action_type', flat=True))
class Meta:
model = WebHook
fields = ['target_url', 'enabled', 'all_events', 'limit_events']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events'
}),
}

View File

@@ -12,7 +12,7 @@ from pretix.base.models.event import SubEvent, SubEventMetaValue
from pretix.base.models.items import SubEventItem
from pretix.base.reldate import RelativeDateTimeField
from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimePickerWidget
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.helpers.money import change_decimal_field
@@ -37,11 +37,11 @@ class SubEventForm(I18nModelForm):
'frontpage_text'
]
field_classes = {
'date_from': forms.SplitDateTimeField,
'date_to': forms.SplitDateTimeField,
'date_admission': forms.SplitDateTimeField,
'presale_start': forms.SplitDateTimeField,
'presale_end': forms.SplitDateTimeField,
'date_from': SplitDateTimeField,
'date_to': SplitDateTimeField,
'date_admission': SplitDateTimeField,
'presale_start': SplitDateTimeField,
'presale_end': SplitDateTimeField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(),

View File

@@ -6,7 +6,7 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.forms import I18nModelForm
from pretix.base.models import Item, Voucher
from pretix.control.forms import SplitDateTimePickerWidget
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import voucher_form_validation
from pretix.helpers.models import modelcopy
@@ -34,7 +34,7 @@ class VoucherForm(I18nModelForm):
'comment', 'max_usages', 'price_mode', 'subevent'
]
field_classes = {
'valid_until': forms.SplitDateTimeField,
'valid_until': SplitDateTimeField,
}
widgets = {
'valid_until': SplitDateTimePickerWidget(),
@@ -190,7 +190,7 @@ class VoucherBulkForm(VoucherForm):
'max_usages', 'price_mode', 'subevent'
]
field_classes = {
'valid_until': forms.SplitDateTimeField,
'valid_until': SplitDateTimeField,
}
widgets = {
'valid_until': SplitDateTimePickerWidget(),

View File

@@ -123,13 +123,13 @@ def _display_checkin(event, logentry):
if data.get('first'):
if show_dt:
return _('Position #{posid} has been scanned at {datetime} for list "{list}".').format(
return _('Position #{posid} has been checked in at {datetime} for list "{list}".').format(
posid=data.get('positionid'),
datetime=dt_formatted,
list=checkin_list
)
else:
return _('Position #{posid} has been scanned for list "{list}".').format(
return _('Position #{posid} has been checked in for list "{list}".').format(
posid=data.get('positionid'),
list=checkin_list
)
@@ -198,6 +198,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'),
'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'),
'pretix.event.order.payment.started': _('Payment {local_id} has been started.'),
'pretix.event.order.payment.failed': _('Payment {local_id} has failed.'),
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
@@ -213,10 +215,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.user.settings.notifications.enabled': _('Notifications have been enabled.'),
'pretix.user.settings.notifications.disabled': _('Notifications have been disabled.'),
'pretix.user.settings.notifications.changed': _('Your notification settings have been changed.'),
'pretix.user.anonymized': _('This user has been anonymized.'),
'pretix.user.oauth.authorized': _('The application "{application_name}" has been authorized to access your '
'account.'),
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'),
'pretix.voucher.added': _('The voucher has been created.'),
'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'),
'pretix.voucher.changed': _('The voucher has been changed.'),
@@ -274,6 +278,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.subevent.quota.added': pgettext_lazy('subevent', 'A quota has been added to the event date.'),
'pretix.subevent.quota.changed': pgettext_lazy('subevent', 'A quota has been changed on the event date.'),
'pretix.subevent.quota.deleted': pgettext_lazy('subevent', 'A quota has been removed from the event date.'),
'pretix.device.created': _('The device has been created.'),
'pretix.device.changed': _('The device has been changed.'),
'pretix.device.revoked': _('Access of the device has been revoked.'),
'pretix.device.initialized': _('The device has been initialized.'),
'pretix.device.keyroll': _('The access token of the device has been regenerated.'),
'pretix.device.updated': _('The device has notified the server of an hardware or software update.'),
}
data = json.loads(logentry.data)
@@ -311,6 +321,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
return _display_checkin(sender, logentry)
if logentry.action_type == 'pretix.control.views.checkin':
# deprecated
dt = dateutil.parser.parse(data.get('datetime'))
tz = pytz.timezone(sender.settings.timezone)
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
@@ -334,7 +345,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
list=checkin_list
)
if logentry.action_type == 'pretix.control.views.checkin.reverted':
if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'):
if 'list' in data:
try:
checkin_list = sender.checkin_lists.get(pk=data.get('list')).name

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