Compare commits

...

341 Commits

Author SHA1 Message Date
Raphael Michel
31d1fc31cd Bump version to 3.8.0 2020-04-17 17:19:02 +02:00
Raphael Michel
597211d83a Merge pull request #1646 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-04-17 17:11:24 +02:00
Raphael Michel
17679d4304 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3609 of 3609 strings)

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

powered by weblate
2020-04-17 17:11:08 +02:00
Raphael Michel
0fb70c78a9 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3609 of 3609 strings)

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

powered by weblate
2020-04-17 17:11:07 +02:00
Raphael Michel
e254e90e49 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-04-17 16:26:52 +02:00
Raphael Michel
9c6e5f025d Merge pull request #1642 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-04-17 16:26:21 +02:00
Raphael Michel
3c86532218 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3601 of 3601 strings)

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

powered by weblate
2020-04-17 16:25:29 +02:00
Raphael Michel
3834ae566f Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3601 of 3601 strings)

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

powered by weblate
2020-04-17 16:25:29 +02:00
Raphael Michel
6766b2b19e Fix #1645 -- Allow one-letter event slugs 2020-04-17 16:25:04 +02:00
Raphael Michel
b6d2f67c7c Cache sorting of countries 2020-04-17 13:21:13 +02:00
Raphael Michel
e70f593a94 Minor SQL performance improvements 2020-04-17 12:14:37 +02:00
Raphael Michel
ed5726fc0c Merge branch 'master' of github.com:pretix/pretix 2020-04-17 12:04:41 +02:00
Martin Gross
5400d26c60 Log the reason for failed PayPal refunds 2020-04-17 12:02:43 +02:00
Raphael Michel
0bb6104532 Flip order of invoices and tickets in email attachments 2020-04-16 13:14:06 +02:00
Raphael Michel
16aa403735 Fix incorrect checkin list tests 2020-04-15 13:06:24 +02:00
Martin Gross
1c279a92a7 Merge pull request #1643 from pretix/event_cancellation_giftcard_refund
Allow to issue gift card refunds when cancelling whole events
2020-04-15 13:00:41 +02:00
Martin Gross
35985dcb11 Remove legal warning, make refund_as_giftcard-option clearer. 2020-04-15 12:58:43 +02:00
Raphael Michel
b0dcbe31fa Fix incorrect quota error when changing subevent and item of a position 2020-04-15 12:54:57 +02:00
Martin Gross
b3c3ee3b22 Allow to issue gift card refunds when cancelling whole events 2020-04-15 10:08:12 +02:00
Raphael Michel
aab340fd87 Fix missing translation context 2020-04-15 09:43:58 +02:00
Raphael Michel
1871324ef4 Restrict length of item name in quick setup
PRETIXEU-21V
2020-04-15 09:20:55 +02:00
Raphael Michel
d799d560b7 Fix typo in settings definition 2020-04-14 09:47:47 +02:00
Raphael Michel
01e2851a76 Invoice model: Do not crash on invalid states 2020-04-14 09:47:34 +02:00
Raphael Michel
ef2a4244ed Merge branch 'master' of github.com:pretix/pretix 2020-04-09 09:29:58 +02:00
Raphael Michel
55539dc8e5 Fix confusing background color in subevents 2020-04-09 09:29:40 +02:00
Martin Gross
ef303bfcc4 Add cancel_allow_user_paid_adjust_fees_explanation (#1639)
* Add cancel_allow_user_paid_adjust_fees_explanation

* Cleanup
2020-04-08 17:49:36 +02:00
Martin Gross
fff9ac04a9 Fix test (Caused by 31fdf8721b) 2020-04-08 17:49:16 +02:00
Martin Gross
76d27fbfaa Cleanup 2020-04-08 17:08:58 +02:00
Martin Gross
2b1123b487 Add cancel_allow_user_paid_adjust_fees_explanation 2020-04-08 16:43:29 +02:00
pretix translation bot
3607d8706d Translations update from Weblate (#1638)
* Translated on translate.pretix.eu (German)

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

* Translated on translate.pretix.eu (German (informal))

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Dutch)

Currently translated at 100.0% (3601 of 3601 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Dutch (informal))

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Dutch (informal))

Currently translated at 100.0% (3601 of 3601 strings)

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

powered by weblate

Co-authored-by: Martin Gross <martin@pc-coholic.de>
Co-authored-by: Maarten van den Berg <maartenberg1@gmail.com>
2020-04-08 15:48:55 +02:00
Raphael Michel
31fdf8721b API: Fix selecting checkin question answers by option identifier 2020-04-07 15:16:58 +02:00
Raphael Michel
128a1f349a Revert accidentally commited change 2020-04-03 14:47:44 +02:00
Raphael Michel
7d432f0639 Improve display of attendee addresses 2020-04-03 14:35:38 +02:00
Raphael Michel
1ffc799c4d Split banner text into top and bottom 2020-04-03 13:02:23 +02:00
Raphael Michel
25dd8f2e2f Introduce banner text 2020-04-02 18:35:28 +02:00
Raphael Michel
b121596e4b Merge pull request #1635 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-04-02 18:28:50 +02:00
Raphael Michel
cf835df62e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3601 of 3601 strings)

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

powered by weblate
2020-04-02 18:28:27 +02:00
Raphael Michel
7a3b7d4f02 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3601 of 3601 strings)

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

powered by weblate
2020-04-02 18:28:26 +02:00
Maarten van den Berg
b151d8f455 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3590 of 3590 strings)

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

powered by weblate
2020-04-02 18:17:28 +02:00
Raphael Michel
06de74d877 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-04-02 18:17:18 +02:00
Raphael Michel
2ae9e3e0d9 Directly load editor on supported browsers 2020-04-02 18:16:33 +02:00
Raphael Michel
0c0fe58bbf Improve UX of ticket download settings 2020-04-02 18:16:22 +02:00
Raphael Michel
7b1e1a48ef Enable PDF and passbook outputs for new events by default 2020-04-02 18:03:26 +02:00
Raphael Michel
c7dd50de0d CartMixin: Prevent None values in sorting function 2020-04-02 16:53:17 +02:00
Raphael Michel
a1caa65776 Revert "Upgrade jQuery version (but keep old one around for now for plugins)"
We've got to many instances of this around:
https://stackoverflow.com/questions/38871753/uncaught-typeerror-a-indexof-is-not-a-function-error-when-opening-new-foundat

This reverts commit cc46d55f5e.
2020-04-02 16:42:54 +02:00
Raphael Michel
260973345d Merge pull request #1634 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-04-02 14:41:19 +02:00
Raphael Michel
2c9b2620ea Add company and address fields to attendees (#1633)
* Add company and address fields to attendees

* Update src/pretix/control/templates/pretixcontrol/event/settings.html

Co-Authored-By: Martin Gross <gross@rami.io>

Co-authored-by: Martin Gross <gross@rami.io>
2020-04-02 14:41:09 +02:00
Maarten van den Berg
909c80e710 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate
2020-04-02 09:51:33 +02:00
Maarten van den Berg
5a218ae6a9 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3590 of 3590 strings)

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

powered by weblate
2020-04-02 09:51:33 +02:00
Raphael Michel
b498d45621 Pass gift_cards to order_fee_calculation 2020-04-02 09:50:44 +02:00
Raphael Michel
b02196434b Re-compute fees after applying a giftcard 2020-04-01 17:09:58 +02:00
Raphael Michel
c0edce7760 AJAX: Do not throw error on "abort" in Safari 2020-04-01 16:06:46 +02:00
Raphael Michel
cc46d55f5e Upgrade jQuery version (but keep old one around for now for plugins) 2020-04-01 16:06:46 +02:00
Martin Gross
ea8abb8dab Add Giftcard Redemption Export 2020-04-01 15:46:40 +02:00
Raphael Michel
f765d094b4 Fix crash in UCBrowser (PRETIXEU-20M) 2020-04-01 11:14:30 +02:00
Raphael Michel
86f222870d Cancelling orders: Do not send email to organizer if the refund is in transit 2020-03-31 17:56:51 +02:00
Martin Gross
19b5270d76 Fix tests (Caused by 61a1368ed2) 2020-03-31 14:34:10 +02:00
Martin Gross
db76b9b0ef Tiny fix to make Edge happy and display cancellation-slider 2020-03-31 14:04:57 +02:00
pretix translation bot
d23e53873f Translations update from Weblate (#1632)
* Translated on translate.pretix.eu (German)

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

* Translated on translate.pretix.eu (German (informal))

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

Co-authored-by: Martin Gross <martin@pc-coholic.de>
2020-03-31 13:35:14 +02:00
pretix translation bot
c116a4b998 Translations update from Weblate (#1631)
* Translated on translate.pretix.eu (German)

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

* Translated on translate.pretix.eu (German (informal))

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Dutch)

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Dutch (informal))

Currently translated at 100.0% (106 of 106 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Dutch)

Currently translated at 99.4% (3569 of 3589 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Dutch)

Currently translated at 100.0% (3589 of 3589 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Dutch (informal))

Currently translated at 100.0% (3589 of 3589 strings)

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

powered by weblate

Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Maarten van den Berg <maartenberg1@gmail.com>
2020-03-31 13:28:34 +02:00
Martin Gross
2471d4bca5 Update po files
[CI skip]

Signed-off-by: Martin Gross <gross@rami.io>
2020-03-31 12:47:09 +02:00
Martin Gross
8e04dbdcca Change "we keep" to "The organizer keeps" to avoid confusion 2020-03-31 12:45:32 +02:00
Raphael Michel
0928358396 Fix > vs >= in gift card message 2020-03-29 13:53:58 +02:00
Raphael Michel
23f783c15c Force django-libsass version to be upgraded 2020-03-26 20:51:54 +01:00
Raphael Michel
edae96c84f Fix TypeError during cancellation 2020-03-26 20:49:53 +01:00
Martin Gross
242ebdfae9 Show Subevent start time in select2-pickers (#1630)
* Add Subevent time to __str__

* Show subevent-dates in select2 picker

* Show event-dateblock (if enabled) on Widget Voucher redemption page

* Update src/pretix/base/models/event.py

Co-Authored-By: Raphael Michel <michel@rami.io>

* Update src/pretix/control/templates/pretixcontrol/vouchers/index.html

Co-Authored-By: Raphael Michel <michel@rami.io>

* Update src/pretix/control/views/typeahead.py

Co-Authored-By: Raphael Michel <michel@rami.io>

* Remove date-block on non-subevent voucher redemption pages

Co-authored-by: Raphael Michel <michel@rami.io>
2020-03-26 13:10:45 +01:00
Raphael Michel
0ee502abec Improve performance by not re-evaluating main context processors on
every template rendering in a signal receiver
2020-03-26 09:50:58 +01:00
Raphael Michel
29cb1e93d8 Reduce number of queries on the order change form 2020-03-26 09:50:14 +01:00
Raphael Michel
c89242855c Reduce number of SQL queries on order detail page 2020-03-26 09:38:56 +01:00
Raphael Michel
61a1368ed2 Widget: Show date and time of subevent after calendar selection 2020-03-25 17:48:24 +01:00
Raphael Michel
ac3e00fa03 Color buttons green if they otherwise look like a headline 2020-03-25 17:48:09 +01:00
Raphael Michel
d9d0f7b6f3 Add Order.cancellation_date (#1629)
* Add Order.cancellation_date

* Add tests
2020-03-25 16:37:34 +01:00
Raphael Michel
ad5e2df3be Merge pull request #1628 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-03-25 14:59:07 +01:00
Raphael Michel
ec34561815 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3589 of 3589 strings)

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

powered by weblate
2020-03-25 14:58:32 +01:00
Raphael Michel
e1540b1648 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3589 of 3589 strings)

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

powered by weblate
2020-03-25 14:58:31 +01:00
Raphael Michel
a6b265455d Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-03-25 14:14:28 +01:00
Raphael Michel
8a6334bd86 Introduce cancellation requests (#1627)
* Allow to adjust the cancellation fee without JS

* Introduce cancellation requests

* ignore→delete

* Change a few things after Martin's review

* Add a few tests
2020-03-25 14:13:55 +01:00
Raphael Michel
173a23722a Merge pull request #1625 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-03-25 14:12:26 +01:00
Raphael Michel
ab8eb2a34d Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3546 of 3546 strings)

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

powered by weblate
2020-03-25 13:03:51 +01:00
pajowu
30dcda616b Send checkin list mapping in event_copy_data signal (#1624)
* Send checkin list map in event_copy_data signal

* Add checkin_list_map to documentation and definition of event_copy_data
2020-03-25 13:03:45 +01:00
Raphael Michel
3eafec9d6e Allow customers to choose to receive their refund as a gift card (#1626)
* Minor text adjustments

* Allow users to receive their cancellation as a gift card
2020-03-25 11:41:40 +01:00
Raphael Michel
a5910016fd Allow users to increase cancellation fees (#1622)
* Allow users to increase cancellation fees

* Fix typo
2020-03-25 10:11:29 +01:00
pajowu
0a49b93b26 Render remaining badges onto new page even if it doesn't fill t… (#1621) 2020-03-24 17:43:58 +01:00
Raphael Michel
7449bea836 Add request argument to order_info/position_info signals 2020-03-24 15:57:00 +01:00
Raphael Michel
0fc4478332 Add docs on pretix-digital 2020-03-24 15:56:51 +01:00
Raphael Michel
0df4a6e7ed Add signals order_info_top and position_info_top 2020-03-24 14:22:02 +01:00
Benjamin Hättasch
a37cd380c8 Fix sample service config (#1620) 2020-03-24 09:10:56 +01:00
Raphael Michel
11b2bd8887 Allow markdown in multiple-choice question options 2020-03-23 17:55:52 +01:00
Raphael Michel
8986db0975 Merge pull request #1619 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-03-23 17:55:50 +01:00
Raphael Michel
2921611cb1 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3546 of 3546 strings)

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

powered by weblate
2020-03-23 17:55:20 +01:00
Raphael Michel
785fb29513 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3546 of 3546 strings)

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

powered by weblate
2020-03-23 17:55:19 +01:00
Raphael Michel
81c3d7fa17 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-03-23 17:02:11 +01:00
Raphael Michel
8ff963698d Text change "Modify" → "Add or remove tickets" on review order page 2020-03-23 16:47:25 +01:00
Raphael Michel
6da63e0169 Question form: Guess default country 2020-03-23 16:44:37 +01:00
Raphael Michel
f84903ae27 Drop removed context attribute from from_db_value 2020-03-23 16:44:37 +01:00
Raphael Michel
a0a7859b33 Update redis 2020-03-23 16:03:19 +01:00
Raphael Michel
af23d6e4bf Upgrade to Django 3.0 and other dependencies (#1568)
* Upgrade Django to 3.0 and other dependencies to recent versions

* Fix otp version contsraint

* Remove six dependency

* Resolve some warnings

* Fix failing tests

* Update django-countries

* Resolve all RemovedInDjango31Warnings in test suite

* Run isort

* Fix import

* Update PostgreSQL version on travis
2020-03-23 15:02:20 +01:00
Raphael Michel
7e9c9beace Allow to use a custom domain per event (#1617)
* Drop support for maindomain_urls/subdomain_urls in plugins

* Allow to use a custom domain per event

* Fix bug when manually saving domains

* Fix custom domains in debugging

* Fix middleware

* Fix middleware again, update docs
2020-03-23 13:03:14 +01:00
Raphael Michel
ac2fc2de5c Cancelling events: Allow to cancel all dates in a series 2020-03-23 10:37:10 +01:00
Raphael Michel
45e548873e Do not crash event page on social image generation 2020-03-22 11:04:51 +01:00
Raphael Michel
f484eb65df PayPal: Do not crash on failed refund 2020-03-22 11:04:51 +01:00
Raphael Michel
027a785ab5 Log out other sessions after email or 2FA changes 2020-03-22 11:04:51 +01:00
Raphael Michel
25b80cbb57 Fix invalid requirement spec 2020-03-20 12:46:15 +01:00
Raphael Michel
589fa0f9de Cancelling events: Send email even if refund failed 2020-03-20 12:38:57 +01:00
Raphael Michel
6d2989d15a Run a forked version of vat_moss 2020-03-20 11:06:19 +01:00
Raphael Michel
5bb27b29ae Seat statistics: Only use active variations 2020-03-16 16:32:34 +01:00
Raphael Michel
d17f8a71e6 Fix crashes in new statistical feature 2020-03-16 16:12:48 +01:00
Raphael Michel
b664cc712a Cancelling events: Allow to create manual and partial refunds 2020-03-16 16:00:44 +01:00
Raphael Michel
d61e8a9204 Cancelling events: Allow to select fee types to keep 2020-03-16 15:44:37 +01:00
Martin Gross
f00012a63e Statistics on sold and unsold seats, as well as potential profi… (#1613)
* Statistics on sold and unsold seats, as well as potential profits

* Rework of seats-stats

* Fix crash when all seats are assigned

* Update src/pretix/plugins/statistics/views.py

Co-Authored-By: Raphael Michel <michel@rami.io>

* Update src/pretix/plugins/statistics/views.py

Co-Authored-By: Raphael Michel <michel@rami.io>

* Update src/pretix/plugins/statistics/views.py

Co-Authored-By: Raphael Michel <michel@rami.io>

* Fix count of sold seats

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
Co-authored-by: Raphael Michel <michel@rami.io>
2020-03-16 15:20:30 +01:00
Raphael Michel
bd238f76ce Fix broken test 2020-03-16 13:29:12 +01:00
Raphael Michel
703ae97820 Fix typo in German translation 2020-03-16 13:27:33 +01:00
Raphael Michel
1a60c5ea64 Fix missing bleach call in invoice renderer 2020-03-16 11:44:31 +01:00
Raphael Michel
1d3ac5f02f Fix StaticDevice.MultipleObjectsReturned
PRETIXEU-1YX
2020-03-16 11:41:33 +01:00
Raphael Michel
8d23d75dfd Only send download reminders if there's actually a download 2020-03-16 09:26:11 +01:00
Maico Timmerman
9a32668ee1 Make next url authentication backend dependent (#1609)
* Make next url authentication backend dependent

* Rename authentication next_url to get_next_url.

* Add test for custom authentication backend get_next_url.

* Fix typo in docstring of authentication backend get_next_url.
2020-03-15 11:05:57 +01:00
pajowu
ca0407a133 Subclass MultipleChoiceField to serialize to list (#1605)
* Subclass MultipleChoiceField to serialize to list

* Rename pretix.api.serializers.MultipleChoiceField to ListMultipleChoiceField

* Keep order in ListMultipleChoiceField
2020-03-14 22:04:12 +01:00
Raphael Michel
1de77b0784 Translations update from Weblate (#1612)
Translations update from Weblate
2020-03-14 22:02:09 +01:00
Maarten van den Berg
d0907d3dcf Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3516 of 3516 strings)

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

powered by weblate
2020-03-13 08:00:11 +01:00
pajowu
81cc4bd768 Do not validate event creation form on back button (#1611) 2020-03-12 19:17:49 +01:00
pajowu
262639e063 Show checkbox if items per order limit is 1 (#1610) 2020-03-12 18:47:14 +01:00
Martin Gross
dedd93fb89 Add Support for Square POS payment_data 2020-03-12 14:33:37 +01:00
Raphael Michel
45f94aee03 Merge pull request #1602 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-03-12 13:31:40 +01:00
Maarten van den Berg
d36e7d033f Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3516 of 3516 strings)

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

powered by weblate
2020-03-12 13:31:17 +01:00
Maarten van den Berg
b94bd277bf Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3516 of 3516 strings)

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

powered by weblate
2020-03-12 13:31:16 +01:00
Maico Timmerman
e5095185d9 Send widget_data in voucher redeem request. (#1606) 2020-03-12 13:31:12 +01:00
pajowu
d76ce47597 Allow creating question with dependency_question=None (#1607) 2020-03-12 13:30:18 +01:00
Martin Gross
58717850c2 Add date-on-frontpage check to secondary template 2020-03-12 12:06:26 +01:00
Martin Gross
29d52d4fe5 Second attempt at hiding date on frontpage in title 2020-03-12 12:00:18 +01:00
Martin Gross
34c9c40ddc Option to hide date-block on frontpage (#1603)
* Option to hide date-block on frontpage

* Also hide date in headline
2020-03-12 10:28:47 +01:00
Martin Gross
39d05a6c40 Duplicate generate_ticket for items on duplication 2020-03-11 18:44:29 +01:00
Martin Gross
b664222c62 Calculation fix for unlimited Quota w/ Waitinglist 2020-03-11 16:48:57 +01:00
Raphael Michel
1ee48a10b5 Allow to reactivate canceled orders (#1601) 2020-03-11 11:40:56 +01:00
Raphael Michel
2431a8b767 Fix typo 2020-03-09 17:37:13 +01:00
Raphael Michel
af84354e51 Resolve some warnings in the test suite 2020-03-09 14:57:19 +01:00
Raphael Michel
b04de880fc Bump to 3.8.0.dev0 2020-03-09 14:34:00 +01:00
Raphael Michel
11f3057f76 Bump version to 3.7.0 2020-03-09 14:33:17 +01:00
Raphael Michel
ba164c16f6 Fix test failure that only happens in the 20 days before daylight saving time changes 2020-03-09 12:56:58 +01:00
Raphael Michel
7ef766ddfa Cancelling events: Only show email fields conditionally 2020-03-09 11:44:25 +01:00
Raphael Michel
bcafcc7dd8 Fix invalid translation file 2020-03-09 11:36:59 +01:00
Raphael Michel
e5b7102abc Merge pull request #1600 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-03-09 11:21:33 +01:00
Raphael Michel
3601dd6bee Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3516 of 3516 strings)

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

powered by weblate
2020-03-09 11:20:45 +01:00
Raphael Michel
a1d5854fbf Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3516 of 3516 strings)

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

powered by weblate
2020-03-09 11:20:44 +01:00
Raphael Michel
09544a688d Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-03-09 11:11:54 +01:00
Raphael Michel
58a5892cc0 Merge pull request #1598 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-03-09 11:11:12 +01:00
Maarten van den Berg
c9af76b46e Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 97.9% (3436 of 3510 strings)

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

powered by weblate
2020-03-09 01:00:13 +01:00
Maarten van den Berg
91753935cf Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3510 of 3510 strings)

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

powered by weblate
2020-03-09 01:00:12 +01:00
Raphael Michel
23a52eb12a Add lang attribute to <html> tag in presale 2020-03-08 15:23:47 +01:00
Raphael Michel
79ecb231f2 Fix placement of back link when a header is used 2020-03-08 15:07:59 +01:00
Raphael Michel
08de7f59a3 Move iframe.js into main JS file 2020-03-08 15:07:59 +01:00
Raphael Michel
0de3c33bab Remove unused bootstrap components from CSS 2020-03-08 15:07:59 +01:00
Raphael Michel
a4ae8b0e66 Include lightbox in main.scss file 2020-03-08 15:07:59 +01:00
Raphael Michel
be1bf81298 Add font-display: swap to custom shop fonts 2020-03-08 15:07:59 +01:00
Raphael Michel
b7528ae1cf Cancelling events: Fix incorrect refund amount in emails 2020-03-06 18:09:38 +01:00
Raphael Michel
4f6712ccbe OrderChangeManager: Prevent creation of 0 € invoices 2020-03-06 17:05:15 +01:00
Raphael Michel
939335f94b Widget: Fix incorrect computation of cache key 2020-03-06 17:00:47 +01:00
Raphael Michel
c849276a35 Widget: Allow to filter by product 2020-03-06 17:00:26 +01:00
Raphael Michel
8e9f0f07a1 Optimize performance of waiting list dashboard tile 2020-03-06 14:03:28 +01:00
Raphael Michel
389884d191 shell_scoped: Fall back to shell if shell_plus is not installed 2020-03-06 14:03:06 +01:00
Raphael Michel
d8c2c82da7 Update invoice language in build_invoice 2020-03-06 09:36:20 +01:00
Raphael Michel
c3ed3d4899 Adjust tests for last commit 2020-03-05 18:12:40 +01:00
Raphael Michel
e9235cd433 Bank transfer: ignore order status when manually assigning amount 2020-03-05 17:37:54 +01:00
Raphael Michel
975b6d800a Fix hardcoded PK in test 2020-03-05 17:09:33 +01:00
Raphael Michel
ee260c8231 API: Allow to simulate orders 2020-03-05 16:37:55 +01:00
Raphael Michel
f7fddc05dd Improve caching of widget.js 2020-03-05 13:09:42 +01:00
Raphael Michel
eaa61c7795 Add @gzip_page to widget views 2020-03-05 12:53:17 +01:00
Raphael Michel
d4994258e6 Avoid issues with duplicate ItemBundles 2020-03-05 12:53:02 +01:00
Raphael Michel
9b50ec2d74 Cancelling events: Allow to inform waiting list 2020-03-05 10:22:59 +01:00
Raphael Michel
447b6b7fee Waiting list: Do not send notifications for disabled items 2020-03-05 09:37:35 +01:00
Raphael Michel
40f763c999 Cancelling events: Create special log entry 2020-03-05 09:37:17 +01:00
Raphael Michel
6a3d05be9e Cancelling events: Fix invalid email logging 2020-03-05 09:23:07 +01:00
Raphael Michel
766447f021 Fix typo in form field name 2020-03-04 14:45:54 +01:00
Raphael Michel
5fbeb90f00 Order import: Fix crash on unknown delimiter
PRETIXEU-1Y0
2020-03-04 14:44:55 +01:00
Raphael Michel
c01cc85eda Merge pull request #1597 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-03-03 17:13:10 +01:00
Raphael Michel
4a054da6ee Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3510 of 3510 strings)

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

powered by weblate
2020-03-03 17:11:12 +01:00
Raphael Michel
583a2b6572 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3510 of 3510 strings)

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

powered by weblate
2020-03-03 17:11:11 +01:00
Raphael Michel
fbe025afb2 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3488 of 3488 strings)

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

powered by weblate
2020-03-03 16:56:06 +01:00
Raphael Michel
66e6191122 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3488 of 3488 strings)

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

powered by weblate
2020-03-03 16:56:05 +01:00
Raphael Michel
0f26f0787c Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-03-03 16:55:56 +01:00
Raphael Michel
62a86c9b4a Allow to cancel all orders in an event (#1596)
* Allow to cancel all orders in an event

* Add tests

* Actually add tests
2020-03-03 16:55:05 +01:00
Raphael Michel
07318be4c9 Fix incorrect total in invoice preview 2020-03-03 16:45:18 +01:00
Raphael Michel
3c8ef2c620 Fix incorrect result message 2020-03-03 16:43:36 +01:00
Raphael Michel
a858f47220 API: Fix crash when cancelling a redeemed gift card 2020-03-02 13:18:16 +01:00
Raphael Michel
381fa5e1cd Allow to add a text to gift card transactions 2020-03-02 12:47:39 +01:00
Raphael Michel
1539eea664 API: Allow devices to access gift cards 2020-03-02 12:19:19 +01:00
Raphael Michel
3d41d1331a API: Support for cross-organizer gift cards 2020-03-02 12:19:02 +01:00
Raphael Michel
00848b3339 Remove "default" flag from cloned layouts 2020-03-02 09:35:53 +01:00
Raphael Michel
d174b11c6a Reduce retention time of sass compiler cache 2020-02-28 16:21:34 +01:00
Raphael Michel
a501ce496a Fix broken sass compiler cache 2020-02-28 16:21:18 +01:00
Raphael Michel
de277cc959 Badges, PDF tickets: Allow to copy layouts 2020-02-28 12:50:31 +01:00
Raphael Michel
12b3ae81d6 Mark payment_term_days settings as required in the backend 2020-02-28 12:24:00 +01:00
Raphael Michel
fcdb40dda0 Refs #1593 -- Fix subsequent issue when clearing the cart 2020-02-27 16:28:07 +01:00
Raphael Michel
f65cf8e86a Fix checks for min_per_order and max_per_order in combination with variations 2020-02-27 15:08:35 +01:00
Raphael Michel
12540238b7 Ignore replica database in tests 2020-02-27 14:44:20 +01:00
Raphael Michel
398a30e33a Merge pull request #1592 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-02-27 11:05:27 +01:00
Raphael Michel
3410640618 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3488 of 3488 strings)

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

powered by weblate
2020-02-27 11:05:05 +01:00
Raphael Michel
7b45cfccc2 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3488 of 3488 strings)

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

powered by weblate
2020-02-27 11:05:04 +01:00
Raphael Michel
33f503aea1 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-02-27 10:55:11 +01:00
Raphael Michel
3fd650081b Allowing more options to style pretix shops (#1585)
* Fix caching issues in SASS compilation

* Allow to set a custom page background color

* Allow to disable round corners

* Support larger header pictures

* Allow to show title despite header

* Move language picker

* FIx widget styles
2020-02-27 10:54:00 +01:00
Martin Gross
b622854be6 Fix versionchanged for item_meta_data 2020-02-27 09:25:44 +01:00
Raphael Michel
6d00daa9ee Fix crash when sending mails with empty mail_from 2020-02-26 17:34:37 +01:00
Raphael Michel
f27148998a Support for itemmeta: objects in the API 2020-02-26 15:59:42 +01:00
Raphael Michel
4a0369cc37 REbase migration 2020-02-26 15:22:41 +01:00
Martin Gross
76aaf61e19 Add meta_data for items (#1576)
* PoC for ItemMetaProperties/Values

* Missing is_valid

* ItemMetaProperties/Values in editable via API, cloneable

* Tests

* Add Docs

* Fix import order

* Fix another import sorting...

* Typeahead for ItemMetaValues

* Test for editing event-objects

* Fix typeahead permission checks

* Further access restriction

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2020-02-26 15:06:24 +01:00
Raphael Michel
dd1e5fa929 Test suite: Allow to cancel expired orders 2020-02-26 11:56:23 +01:00
Raphael Michel
4a2516e303 Allow to cancel expired orders 2020-02-26 11:23:52 +01:00
Raphael Michel
cf06712eca Merge pull request #1590 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-02-26 08:51:30 +01:00
Maarten van den Berg
6185d675f0 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 97.5% (3388 of 3474 strings)

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

powered by weblate
2020-02-26 04:00:13 +01:00
David100mark
a53cd3abce Translated on translate.pretix.eu (French)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2020-02-26 04:00:12 +01:00
David100mark
ebe86a17fb Translated on translate.pretix.eu (French)
Currently translated at 66.9% (2325 of 3474 strings)

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

powered by weblate
2020-02-26 04:00:11 +01:00
David100mark
d189b16ee7 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3474 of 3474 strings)

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

powered by weblate
2020-02-26 04:00:11 +01:00
Raphael Michel
d70ce4491a Merge pull request #1584 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-02-20 16:12:22 +01:00
Maarten van den Berg
607ff48d70 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 97.4% (3382 of 3474 strings)

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

powered by weblate
2020-02-19 18:25:18 +01:00
Maarten van den Berg
4bab44ca85 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3474 of 3474 strings)

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

powered by weblate
2020-02-19 18:25:18 +01:00
Raphael Michel
a5cdb485d0 Fix faulty test cases 2020-02-19 18:25:05 +01:00
Raphael Michel
282ef792c4 Do not show "create new event" if there are no permissions to dos o 2020-02-19 18:24:47 +01:00
Raphael Michel
6cd888a1dc Fix date parsing issue in Danish locale 2020-02-19 18:00:02 +01:00
Raphael Michel
2e5b80c83c Fix missing fees in order overview 2020-02-19 16:09:40 +01:00
Raphael Michel
4511110069 Fix timezone of notifications 2020-02-19 14:28:06 +01:00
Raphael Michel
1af1d8c658 Add rich_text_snippet 2020-02-18 09:45:20 +01:00
Raphael Michel
9f6a3f9a6a Add custom field for invoice addresses 2020-02-18 09:21:00 +01:00
Raphael Michel
1c03d5d305 Invoices: Label tax ID as ABN in Australia 2020-02-18 09:05:09 +01:00
Raphael Michel
69a1fccd20 Fix #1582 - Add explicit requirement to packaging library 2020-02-17 21:00:44 +01:00
Raphael Michel
2a54aa2d83 Bump to 3.7.0.dev0 2020-02-17 16:39:27 +01:00
Raphael Michel
2269c8dee0 Bump version to 3.6.0 2020-02-17 16:38:53 +01:00
Raphael Michel
46295ea887 Merge pull request #1581 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-02-17 16:31:00 +01:00
Raphael Michel
e41863229b Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3474 of 3474 strings)

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

powered by weblate
2020-02-17 16:30:22 +01:00
Raphael Michel
ca5a6ddba1 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3474 of 3474 strings)

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

powered by weblate
2020-02-17 16:30:21 +01:00
Raphael Michel
4d4dafb5dd Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-02-17 16:22:55 +01:00
Raphael Michel
9c2af952b7 Fix another empty sequence error 2020-02-17 14:00:15 +01:00
Raphael Michel
dc6e425c2a Fix accidental string interpolation in a test case 2020-02-17 13:57:36 +01:00
Raphael Michel
5f65b9528f Fix a migration exception 2020-02-17 13:30:19 +01:00
Raphael Michel
8957c2f106 Seating: Support custom row and seat labels 2020-02-17 13:15:49 +01:00
Raphael Michel
2bbbc88a9c Allow duplicate ticket secrets in different organizers 2020-02-17 12:35:02 +01:00
Raphael Michel
162b7c1b52 Fixed subsequent issue with failed paypal payments 2020-02-14 14:22:05 +01:00
Raphael Michel
755f3d53b6 Fix issue with failed paypal payments 2020-02-14 13:24:18 +01:00
Raphael Michel
f6db62d6ce Add retry button to cookie failure page 2020-02-14 11:43:14 +01:00
Raphael Michel
aa1ffc402c Remove some unnecessary queries 2020-02-14 11:25:28 +01:00
Raphael Michel
2c9b96f0c5 Fix various issues with min() statements 2020-02-14 09:16:04 +01:00
Raphael Michel
16599e242d Partially revert last change 2020-02-13 17:39:17 +01:00
Raphael Michel
19c13d7f38 Improved order position secret generation 2020-02-13 17:04:50 +01:00
Raphael Michel
65db8cd583 Widget: Pass referer to cart session 2020-02-13 09:58:08 +01:00
Raphael Michel
d0794d7b94 Optionally allow to automatically reissue an invoice after a data change 2020-02-13 09:49:21 +01:00
Raphael Michel
a770f5a8e7 Add FAQ section on payment deadlines 2020-02-13 09:37:25 +01:00
Raphael Michel
80a3063799 Fix KeyError in sendmail history 2020-02-13 09:22:50 +01:00
Raphael Michel
34ec11ecfa Fix KeyError for form validation 2020-02-13 09:21:23 +01:00
Martin Gross
a1da2eafdc Reinstate MOTO-Flagging for Reseller Scheme-TX (#1570) 2020-02-12 09:49:33 +01:00
Raphael Michel
6bc2175ea9 More robust redis connections 2020-02-12 09:29:30 +01:00
Raphael Michel
21dcb4f43d Revert "Optional MOTO-Flagging for Reseller Scheme-TXs (#1570)"
This reverts commit 0a920ac21c.
2020-02-12 08:01:24 +01:00
Raphael Michel
e9722bcdbd Merge pull request #1578 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-02-11 17:22:04 +01:00
Raphael Michel
e7eb8e3111 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3472 of 3472 strings)

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

powered by weblate
2020-02-11 17:21:00 +01:00
Raphael Michel
a895d83764 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3472 of 3472 strings)

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

powered by weblate
2020-02-11 17:20:59 +01:00
Raphael Michel
b6697b838b Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-02-11 16:55:36 +01:00
Raphael Michel
0d8c4271a9 Show successful payments on the invoice 2020-02-11 16:54:02 +01:00
Raphael Michel
d226bbda5c Order data export: Add column with the number of positions 2020-02-11 16:25:48 +01:00
Raphael Michel
38d0198dea Do not show a flag for single language events 2020-02-11 13:19:46 +01:00
Martin Gross
0a920ac21c Optional MOTO-Flagging for Reseller Scheme-TXs (#1570)
* Optional MOTO-Flagging for Reseller Scheme-TXs

* Update src/pretix/plugins/stripe/payment.py

Co-Authored-By: Raphael Michel <michel@rami.io>

* Update src/pretix/plugins/stripe/payment.py

Co-Authored-By: Raphael Michel <michel@rami.io>

* Manually rebase again...

* Fix a single whitespace for style...

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2020-02-11 11:08:28 +01:00
Raphael Michel
7acee9458d Move bulk_create logic during bulk voucher creation 2020-02-09 12:17:48 +01:00
Raphael Michel
82e40ce664 Merge pull request #1574 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-02-08 13:45:16 +01:00
Raphael Michel
4632269ac3 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3463 of 3463 strings)

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

powered by weblate
2020-02-08 13:44:32 +01:00
Raphael Michel
6d7e1ef53d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3463 of 3463 strings)

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

powered by weblate
2020-02-08 13:44:30 +01:00
Raphael Michel
3ea4cdc3b3 Small categorization adjustments 2020-02-08 13:14:19 +01:00
Raphael Michel
e4619eeca3 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-02-08 12:42:35 +01:00
Raphael Michel
bb5c7c5ad7 Re-introduce plugin categories 2020-02-08 12:38:43 +01:00
Raphael Michel
9984fe97ba Add OrderPayment.fail() to prevent race conditions (#1572) 2020-02-07 09:00:35 +01:00
Martin Gross
242dd24caa Fix hiding of payment methods 2020-02-06 10:53:02 +01:00
Raphael Michel
2482d9390a Fix incorrect settings field type 2020-02-05 21:29:22 +01:00
Raphael Michel
3b4923ccae Fix required field 2020-02-05 18:34:58 +01:00
Raphael Michel
8a2e4385ff Fix required field 2020-02-05 18:33:40 +01:00
Raphael Michel
e83b8ac218 Allow to hide payment methods behind a secret link 2020-02-05 18:09:27 +01:00
Raphael Michel
b387fba5f4 Hide time on list of events for event series 2020-02-05 17:30:15 +01:00
Raphael Michel
da68cb618e Improved logging and transaction handling around payment confirmations 2020-02-05 12:02:02 +01:00
Raphael Michel
eb11dac21e Fix single badge downloads 2020-02-05 10:31:11 +01:00
Raphael Michel
6e531ee067 Event list filters: Deal with events without presale start/end 2020-02-05 10:31:11 +01:00
Raphael Michel
c8e6daa7a1 Do not show disabled sub-methods in timeline 2020-02-05 10:31:11 +01:00
Martin Gross
b3e3d427cb Add more Stripe Payment Methods, simplify forms (#1571)
* Add more Stripe Payment Methods, simplify forms

* Revert accidential commit of boxoffice control renderer...

* Use existing QR-Code encoding in presale
2020-02-05 09:50:29 +01:00
pajowu
6e88054af7 Send signal on checkin (#1546)
* Send signal when orderposition is checked in

* Add position_checked_in signal to documentation

* Rename signal to checkin_created

* Update general.rst

* Update signals.py

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2020-02-04 18:26:35 +01:00
Raphael Michel
22dfa0e61d Use select2 input for category fields 2020-02-04 18:22:03 +01:00
Alexander Schwartz
833cd32578 Comparison for file extensions should be case insensitive (#1563) 2020-02-04 17:09:12 +01:00
Raphael Michel
fd1c964c92 Fix #1378 -- API: Allow to access and modify (some) event setti… (#1569)
* API: Allow to access event settings

* Convert most "general" settings

* Smaller fixes

* Add more settings

* Relative dates, nulling

* Fix a test failure

* Fix wrong attribute access
2020-02-04 17:06:23 +01:00
Raphael Michel
87b10ef055 Allow to print multiple badges on one page (#1380)
* Allow to print multiple badges on one page

* Fix test

* Add more sizes

* Add A4 sizes
2020-02-04 17:02:33 +01:00
Raphael Michel
734f65b10b Ask Google not to index the "Resend order links" site 2020-02-04 16:26:05 +01:00
Martin Gross
0f826a6f76 Show payment card information for Stripe PaymentIntents 2020-02-04 15:09:34 +01:00
Raphael Michel
35e521cc55 Merge pull request #1564 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2020-02-03 17:58:29 +01:00
Martin Gross
63c845574f Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3363 of 3363 strings)

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

powered by weblate
2020-02-03 15:39:33 +01:00
Raphael Michel
5a675cc75d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3363 of 3363 strings)

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

powered by weblate
2020-02-03 15:39:33 +01:00
András Veres-Szentkirályi
994dc9bf76 Translated on translate.pretix.eu (Hungarian)
Currently translated at 2.8% (94 of 3363 strings)

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

powered by weblate
2020-02-03 15:39:33 +01:00
Raphael Michel
cc4a07e3b0 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3363 of 3363 strings)

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

powered by weblate
2020-02-03 15:39:33 +01:00
Raphael Michel
2ca88d5328 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3363 of 3363 strings)

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

powered by weblate
2020-02-03 15:39:33 +01:00
Raphael Michel
0bca9b9bf1 Box office: Support for Stripe Terminal 2020-02-03 15:39:19 +01:00
Raphael Michel
742d2f11be SCSS: Fix font generation error 2020-02-01 16:25:05 +01:00
Raphael Michel
5ea5b82994 Docs: Fix typo 2020-02-01 16:25:00 +01:00
Raphael Michel
81245cf125 Fix #1549 -- JS API to open pretix Button 2020-02-01 15:59:24 +01:00
Raphael Michel
c6bcd05404 Widget: Fix button behaviour without iframe 2020-02-01 15:39:03 +01:00
Raphael Michel
1999a25095 Widget: no reload on buttons 2020-02-01 14:19:39 +01:00
Raphael Michel
62f7c5ba0f Doc spelling: Add SQL to wordlist 2020-02-01 14:08:24 +01:00
Raphael Michel
d11b0e92f1 Reverse default order of subevents 2020-02-01 13:59:55 +01:00
Raphael Michel
662bdea45b Preload all samples 2020-02-01 13:48:56 +01:00
Raphael Michel
d37cc4f641 Font API: Support for script-specific samples 2020-02-01 13:46:50 +01:00
Raphael Michel
6b2bc71be9 Order list export: consistent decimals 2020-02-01 13:06:40 +01:00
Raphael Michel
f267940562 Update Sphinx 2020-02-01 12:57:19 +01:00
Raphael Michel
7140406f35 Order data export: Split multiple choice questions in multiple columns 2020-02-01 12:47:39 +01:00
Raphael Michel
e275e2e240 API: Add endpoint to expose pretix version 2020-01-31 11:11:24 +01:00
Raphael Michel
75c0920f5e API: Allow to mark order as pending when creating refunds 2020-01-31 10:48:32 +01:00
Raphael Michel
b6efe9ae1e Fix middleware name 2020-01-29 12:44:39 +01:00
Raphael Michel
a28378bac9 Fix further problems with middleware ordering 2020-01-29 11:50:09 +01:00
Raphael Michel
a940fa9eb7 Correct order of middlewares, thereby fix event list widget on custom domains
With the incorrect order, Django used the wrong URL config file to
determine whether this URL is valid and APPEND_SLASH kicked in too
often.
2020-01-28 23:38:09 +01:00
Raphael Michel
332fba6168 DOwngrade libsass 2020-01-28 22:46:43 +01:00
Raphael Michel
41655532e9 Upgrade bootstrap to 3.4.0 2020-01-28 22:46:43 +01:00
Martin Gross
6cc9801fe1 Only allow letters, numbers, dots and dashes for giftcard codes 2020-01-28 16:53:44 +01:00
Raphael Michel
29ff5b9416 Use correct timezone for events in event list view 2020-01-28 13:32:43 +01:00
Raphael Michel
889dd651ef Add BasePaymentProvider.matching_id() 2020-01-27 21:46:36 +01:00
Raphael Michel
8c7d7a3055 Add documentation on the campaign module api 2020-01-27 21:24:48 +01:00
Raphael Michel
ff67931c04 Fix a documentation error 2020-01-27 16:51:50 +01:00
Raphael Michel
faa6f0e0a3 Correctly copy seats when copying events 2020-01-27 10:52:25 +01:00
Raphael Michel
68ec37605f Fix another doc typo 2020-01-26 19:35:58 +01:00
Raphael Michel
2ef8b89da0 Add reseller to doc wordlist 2020-01-25 17:16:09 +01:00
Raphael Michel
dfc746ea7a Add documentation on VAR API 2020-01-25 15:26:32 +01:00
Raphael Michel
661546f130 Update from Weblate (#1560)
Update from Weblate
2020-01-25 15:23:29 +01:00
Raphael Michel
5e61342ff5 Add an API for teams (#1562)
* Add Team resource to API

* Add team memer endpoints

* Add team invites endpoint

* Add token endpoints
2020-01-25 15:22:50 +01:00
Prokaj Miklós
4eadfdeec2 Translated on translate.pretix.eu (Hungarian)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2020-01-25 10:07:47 +00:00
Prokaj Miklós
8284a9de44 Translated on translate.pretix.eu (Hungarian)
Currently translated at 1.0% (35 of 3363 strings)

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

powered by weblate
2020-01-25 10:07:47 +00:00
Prokaj Miklós
ae2e70245f Added translation on translate.pretix.eu (Hungarian) 2020-01-25 10:07:47 +00:00
Prokaj Miklós
a92e283a66 Added translation on translate.pretix.eu (Hungarian) 2020-01-25 10:07:47 +00:00
Mie Frydensbjerg
9e2c0d8152 Translated on translate.pretix.eu (Danish)
Currently translated at 47.7% (1603 of 3360 strings)

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

powered by weblate
2020-01-25 10:07:47 +00:00
Raphael Michel
57453a5b00 Fix missing known_errortypes attribute 2020-01-25 11:07:42 +01:00
gnomus
1ccf677ea2 Solve some Docker Setup scaling issues (#1561) 2020-01-24 14:14:36 +01:00
Raphael Michel
0a9daf0d3a API: Fix crash when passing an empty seat_category_mapping during event creation 2020-01-23 14:27:43 +01:00
Raphael Michel
934217ee4f Attach invoices to order changed/canceled emails 2020-01-23 14:25:20 +01:00
Raphael Michel
deff282a63 Do not allow slugs to start with a non-alphanumeric character 2020-01-23 09:39:39 +01:00
Raphael Michel
bcd687764c API: Allow to create payments directly 2020-01-22 17:15:40 +01:00
Raphael Michel
8d7224fecc Force-escape all PDF values on the renderer level (#1556)
* Force-escape all PDF values

* Do not concatenate empty strings to name combinations
2020-01-22 14:56:25 +01:00
Martin Gross
3fff3378c0 Handle media_url of embedded images in simple mail renderer better for instances with dedicated media-hosts 2020-01-21 10:36:46 +01:00
Raphael Michel
91ae89d463 Add cache to sass compiler 2020-01-20 12:04:58 +01:00
Martin Gross
5c0d112def Add simple e-mail-renderer with logo (#1552)
* Add unembellished eMail-renderer

* Fix layout issues with very wide images

* Use prettier padding

* Rename to "simple with logo"

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2020-01-20 11:26:53 +01:00
Raphael Michel
f7ae90811e Merge pull request #1548 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2020-01-20 10:50:34 +01:00
Luka
6ec8c33ecc Translated on translate.pretix.eu (Slovenian)
Currently translated at 20.8% (698 of 3360 strings)

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

powered by weblate
2020-01-20 08:46:31 +00:00
Mie Frydensbjerg
f991d5434f Translated on translate.pretix.eu (Danish)
Currently translated at 47.7% (1602 of 3360 strings)

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

powered by weblate
2020-01-20 08:46:31 +00:00
Raphael Michel
7cf1688de5 PDF: Allow to use combinations of attendee name parts 2020-01-20 09:46:09 +01:00
Raphael Michel
298b3c3660 Consistently put original price in its own line 2020-01-17 17:04:13 +01:00
Raphael Michel
5ea1c96e19 Bump to 3.6.0.dev0 2020-01-12 14:44:25 +01:00
455 changed files with 161866 additions and 90033 deletions

View File

@@ -13,24 +13,24 @@ services:
- postgresql - postgresql
matrix: matrix:
include: include:
- python: 3.7 - python: 3.8
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
- python: 3.7 - python: 3.8
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.7 - python: 3.8
env: JOB=style env: JOB=style
- python: 3.7 - python: 3.8
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
- python: 3.7 - python: 3.8
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.7 - python: 3.7
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.8
env: JOB=doc-spelling env: JOB=doc-spelling
- python: 3.7 - python: 3.8
env: JOB=translation-spelling env: JOB=translation-spelling
addons: addons:
postgresql: "9.4" postgresql: "10"
mariadb: '10.3' mariadb: '10.3'
apt: apt:
packages: packages:

View File

@@ -4,7 +4,7 @@ pid /var/run/nginx.pid;
daemon off; daemon off;
events { events {
worker_connections 768; worker_connections 4096;
} }
http { http {
@@ -39,7 +39,7 @@ http {
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
server { server {
listen 80 default_server; listen 80 backlog=4096 default_server;
listen [::]:80 ipv6only=on default_server; listen [::]:80 ipv6only=on default_server;
server_name _; server_name _;
index index.php index.html; index index.php index.html;

View File

@@ -182,6 +182,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
-v /var/pretix-data:/data \ -v /var/pretix-data:/data \
-v /etc/pretix:/etc/pretix \ -v /etc/pretix:/etc/pretix \
-v /var/run/redis:/var/run/redis \ -v /var/run/redis:/var/run/redis \
--sysctl net.core.somaxconn=4096 \
pretix/standalone:stable all pretix/standalone:stable all
ExecStop=/usr/bin/docker stop %n ExecStop=/usr/bin/docker stop %n

View File

@@ -12,7 +12,7 @@ solution with many things readily set-up, look at :ref:`dockersmallscale`.
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
offers at `pretix.eu`_. offers at `pretix.eu`_.
We tested this guide on the Linux distribution **Debian 8.0** but it should work very similar on other We tested this guide on the Linux distribution **Debian 10.0** but it should work very similar on other
modern distributions, especially on all systemd-based ones. modern distributions, especially on all systemd-based ones.
Requirements Requirements
@@ -133,7 +133,7 @@ command if you're running MySQL::
(venv)$ pip3 install "pretix[postgres]" gunicorn (venv)$ pip3 install "pretix[postgres]" gunicorn
Note that you need Python 3.5 or newer. You can find out your Python version using ``python -V``. Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
We also need to create a data directory:: We also need to create a data directory::

View File

@@ -170,6 +170,19 @@ Date String in ISO 8601 format ``2017-12-27``
Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de_Informal": "rot"}`` Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de_Informal": "rot"}``
Money String with decimal number ``"23.42"`` Money String with decimal number ``"23.42"``
Currency String with ISO 4217 code ``"EUR"``, ``"USD"`` Currency String with ISO 4217 code ``"EUR"``, ``"USD"``
Relative datetime *either* String in ISO 8601 ``"2017-12-27T10:00:00.596934Z"``,
format *or* specification of ``"RELDATE/3/12:00:00/presale_start/"``
a relative datetime,
constructed from a number of
days before the base point,
a time of day, and the base
point.
Relative date *either* String in ISO 8601 ``"2017-12-27"``,
format *or* specification of ``"RELDATE/3/-/presale_start/"``
a relative date,
constructed from a number of
days before the base point
and the base point.
===================== ============================ =================================== ===================== ============================ ===================================
Query parameters Query parameters

View File

@@ -61,7 +61,7 @@ access to the API. The ``token`` endpoint expects you to authenticate using `HTT
ID as a username and your client secret as a password. You are also required to again supply the same ``redirect_uri`` ID as a username and your client secret as a password. You are also required to again supply the same ``redirect_uri``
parameter that you used for the authorization. parameter that you used for the authorization.
.. http:get:: /api/v1/oauth/token .. http:post:: /api/v1/oauth/token
Request a new access token Request a new access token

View File

@@ -0,0 +1,148 @@
pretix Hosted reseller API
==========================
This API is only accessible to our `value-added reseller partners`_ on pretix Hosted.
.. note:: This API is only accessible with user-level permissions, not with API tokens. Therefore, you will need to
create an :ref:`OAuth application <rest-oauth>` and obtain an OAuth access token for a user account that has
permission to your reseller account.
Reseller account resource
-------------------------
The resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Your reseller ID
name string Internal name of your reseller account
public_name string Public name of your reseller account
public_url string Public URL of your company
support_email string Your support email address
support_phone string Your support phone number
communication_language string Language code we use to communicate with you
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/var/
Returns a list of all reseller accounts you have access to.
**Example request**:
.. sourcecode:: http
GET /api/v1/var/ 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": "ticketshop.live Ltd & Co. KG",
"public_name": "ticketshop.live",
"public_url": "https://ticketshop.live",
"support_email": "support@ticketshop.live",
"support_phone": "+4962213217750",
"communication_language": "de"
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:statuscode 200: no error
:statuscode 401: Authentication failure
.. http:get:: /api/v1/var/(id)/
Returns information on one reseller account, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/var/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "ticketshop.live Ltd & Co. KG",
"public_name": "ticketshop.live",
"public_url": "https://ticketshop.live",
"support_email": "support@ticketshop.live",
"support_phone": "+4962213217750",
"communication_language": "de"
}
:param id: The ``id`` field of the reseller account to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 404: The requested account does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/var/(id)/create_organizer/
Creates a new organizer account that will be associated with a given reseller account.
**Example request**:
.. sourcecode:: http
POST /api/v1/var/1/create_organizer/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 123
{
"name": "My new client",
"slug": "New client"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "My new client",
"slug": "New client"
}
:param id: The ``id`` field of the reseller account to fetch
:statuscode 201: no error
:statuscode 400: Invalid request body, usually the slug is invalid or already taken.
:statuscode 401: Authentication failure
:statuscode 404: The requested account does not exist **or** you have no permission to view this resource.
.. _value-added reseller partners: https://pretix.eu/about/en/var

View File

@@ -43,6 +43,7 @@ seating_plan integer If reserved sea
seat_category_mapping object An object mapping categories of the seating plan seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``). (strings) to items in the event (integers or ``null``).
timezone string Event timezone name timezone string Event timezone name
item_meta_properties object Item-specific meta data parameters and default values.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -79,6 +80,10 @@ timezone string Event timezone
The attribute ``timezone`` has been added. The attribute ``timezone`` has been added.
.. versionchanged:: 3.7
The attribute ``item_meta_properties`` has been added.
Endpoints Endpoints
--------- ---------
@@ -133,6 +138,7 @@ Endpoints
"seating_plan": null, "seating_plan": null,
"seat_category_mapping": {}, "seat_category_mapping": {},
"timezone": "Europe/Berlin", "timezone": "Europe/Berlin",
"item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer" "pretix.plugins.banktransfer"
"pretix.plugins.stripe" "pretix.plugins.stripe"
@@ -204,6 +210,7 @@ Endpoints
"seat_category_mapping": {}, "seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"timezone": "Europe/Berlin", "timezone": "Europe/Berlin",
"item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer" "pretix.plugins.banktransfer"
"pretix.plugins.stripe" "pretix.plugins.stripe"
@@ -256,6 +263,7 @@ Endpoints
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"timezone": "Europe/Berlin", "timezone": "Europe/Berlin",
"item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal" "pretix.plugins.paypal"
@@ -290,6 +298,7 @@ Endpoints
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"timezone": "Europe/Berlin", "timezone": "Europe/Berlin",
"item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal" "pretix.plugins.paypal"
@@ -344,6 +353,7 @@ Endpoints
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"timezone": "Europe/Berlin", "timezone": "Europe/Berlin",
"item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal" "pretix.plugins.paypal"
@@ -378,6 +388,7 @@ Endpoints
"seat_category_mapping": {}, "seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"timezone": "Europe/Berlin", "timezone": "Europe/Berlin",
"item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal" "pretix.plugins.paypal"
@@ -444,6 +455,7 @@ Endpoints
"seat_category_mapping": {}, "seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"timezone": "Europe/Berlin", "timezone": "Europe/Berlin",
"item_meta_properties": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer", "pretix.plugins.banktransfer",
"pretix.plugins.stripe", "pretix.plugins.stripe",
@@ -486,3 +498,123 @@ Endpoints
:statuscode 204: no error :statuscode 204: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
Event settings
--------------
pretix events have lots and lots of parameters of different types that are stored in a key-value store on our system.
Since many of these settings depend on each other in complex ways, we can not give direct access to all of these
settings through the API. However, we do expose many of the simple and useful flags through the API.
Please note that the available settings flags change between pretix versions and also between events, depending on the
installed plugins, and we do not give a guarantee on backwards-compatibility like with other parts of the API.
Therefore, we're also not including a list of the options here, but instead recommend to look at the endpoint output
to see available options. The ``explain=true`` flag enables a verbose mode that provides you with human-readable
information about the properties.
.. note:: Please note that this is not a complete representation of all event settings. You will find more settings
in the web interface.
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
able to break your event using this API by creating situations of conflicting settings. Please take care.
.. versionchanged:: 3.6
Initial support for settings has been added to the API.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/settings/
Get current values of event settings.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/settings/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example standard response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"imprint_url": "https://pretix.eu",
}
**Example verbose response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"imprint_url":
{
"value": "https://pretix.eu",
"label": "Imprint URL",
"help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
}
},
}
:param organizer: The ``slug`` field of the organizer of the event to access
:param event: The ``slug`` field of the event to access
:query explain: Set to ``true`` to enable verbose response mode
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
.. warning::
Settings can be stored at different levels in pretix. If a value is not set on event level, a default setting
from a higher level (organizer, global) will be returned. If you explicitly set a setting on event level, it
will no longer be inherited from the higher levels. Therefore, we recommend you to send only settings that you
explicitly want to set on event level. To unset a settings, pass ``null``.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/settings/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"imprint_url": "https://example.org/imprint/"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"imprint_url": "https://example.org/imprint/",
}
:param organizer: The ``slug`` field of the organizer of the event to update
:param event: The ``slug`` field of the event to update
:statuscode 200: no error
:statuscode 400: The event could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.

View File

@@ -59,6 +59,9 @@ Endpoints
} }
:query integer page: The page number in case of a multi-page result set, default is 1 :query integer page: The page number in case of a multi-page result set, default is 1
:query string secret: Only show gift cards with the given secret.
:query boolean testmode: Filter for gift cards that are (not) in test mode.
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -94,6 +97,7 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the gift card to fetch :param id: The ``id`` field of the gift card to fetch
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
@@ -227,6 +231,7 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the gift card to modify :param id: The ``id`` field of the gift card to modify
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.
:statuscode 200: no error :statuscode 200: no error
:statuscode 400: The gift card could not be modified due to invalid submitted data :statuscode 400: The gift card could not be modified due to invalid submitted data
:statuscode 401: Authentication failure :statuscode 401: Authentication failure

View File

@@ -23,6 +23,8 @@ Resources and endpoints
waitinglist waitinglist
giftcards giftcards
carts carts
teams
webhooks webhooks
seatingplans seatingplans
billing_invoices billing_invoices
billing_var

View File

@@ -114,6 +114,7 @@ bundles list of objects Definition of b
└ designated_price money (string) Designated price of the bundled product. This will be └ designated_price money (string) Designated price of the bundled product. This will be
used to split the price of the base item e.g. for mixed used to split the price of the base item e.g. for mixed
taxation. This is not added to the price. taxation. This is not added to the price.
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 2.7 .. versionchanged:: 2.7
@@ -154,6 +155,10 @@ bundles list of objects Definition of b
The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added. The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added.
.. versionchanged:: 3.7
The attribute ``meta_data`` has been added.
Notes Notes
----- -----
@@ -208,6 +213,7 @@ Endpoints
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
@@ -303,6 +309,7 @@ Endpoints
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
@@ -379,6 +386,7 @@ Endpoints
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
@@ -442,6 +450,7 @@ Endpoints
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
@@ -537,6 +546,7 @@ Endpoints
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,

View File

@@ -151,6 +151,10 @@ last_modified datetime Last modificati
The ``order.fees.canceled`` attribute has been added. The ``order.fees.canceled`` attribute has been added.
.. versionchanged:: 3.8
The ``reactivate`` operation has been added.
.. _order-position-resource: .. _order-position-resource:
@@ -173,6 +177,13 @@ price money (string) Price of this p
attendee_name string Specified attendee name for this position (or ``null``) 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_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``) attendee_email string Specified attendee email address for this position (or ``null``)
company string Attendee company name (or ``null``)
street string Attendee street (or ``null``)
zipcode string Attendee ZIP code (or ``null``)
city string Attendee city (or ``null``)
country string Attendee country code (or ``null``)
state string Attendee state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US, otherwise ``null``.
voucher integer Internal ID of the voucher used 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 tax_rate decimal (string) VAT rate applied for this position
tax_value money (string) VAT included in this position tax_value money (string) VAT included in this position
@@ -236,6 +247,10 @@ pdf_data object Data object req
The attribute ``canceled`` has been added. The attribute ``canceled`` has been added.
.. versionchanged:: 3.8
The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -380,6 +395,12 @@ List of all orders
"full_name": "Peter", "full_name": "Peter",
}, },
"attendee_email": null, "attendee_email": null,
"company": "Sample company",
"street": "Test street 12",
"zipcode": "12345",
"city": "Testington",
"country": "DE",
"state": null,
"voucher": null, "voucher": null,
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_value": "0.00", "tax_value": "0.00",
@@ -536,6 +557,12 @@ Fetching individual orders
"full_name": "Peter", "full_name": "Peter",
}, },
"attendee_email": null, "attendee_email": null,
"company": "Sample company",
"street": "Test street 12",
"zipcode": "12345",
"city": "Testington",
"country": "DE",
"state": null,
"voucher": null, "voucher": null,
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
@@ -816,9 +843,9 @@ Creating orders
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the * ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
creation. creation.
* ``email`` * ``email`` (optional)
* ``locale`` * ``locale``
* ``sales_channel`` * ``sales_channel`` (optional)
* ``payment_provider`` (optional) The identifier of the payment provider set for this order. This needs to be an * ``payment_provider`` (optional) The identifier of the payment provider set for this order. This needs to be an
existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"``
for all orders you create as paid. This field is optional when the order status is ``"n"`` or the order total is for all orders you create as paid. This field is optional when the order status is ``"n"`` or the order total is
@@ -851,15 +878,21 @@ Creating orders
* ``positionid`` (optional, see below) * ``positionid`` (optional, see below)
* ``item`` * ``item``
* ``variation`` * ``variation`` (optional)
* ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product) * ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product)
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.) * ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts`` * ``attendee_name`` **or** ``attendee_name_parts`` (optional)
* ``voucher`` (optional, the ``code`` attribute of a valid voucher) * ``voucher`` (optional, the ``code`` attribute of a valid voucher)
* ``attendee_email`` * ``attendee_email`` (optional)
* ``company`` (optional)
* ``street`` (optional)
* ``zipcode`` (optional)
* ``city`` (optional)
* ``country`` (optional)
* ``state`` (optional)
* ``secret`` (optional) * ``secret`` (optional)
* ``addon_to`` (optional, see below) * ``addon_to`` (optional, see below)
* ``subevent`` * ``subevent`` (optional)
* ``answers`` * ``answers``
* ``question`` * ``question``
@@ -891,6 +924,13 @@ Creating orders
IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come
immediately after the position itself. immediately after the position itself.
Starting with pretix 3.7, you can add ``"simulate": true`` to the body to do a "dry run" of your order. This will
validate your order and return you an order object with the resulting prices, but will not create an actual order.
You can use this for testing or to look up prices. In this case, some attributes are ignored, such as whether
to send an email or what payment provider will be used. Note that some returned fields will contain empty values
(e.g. all ``id`` fields of positions will be zero) and some will contain fake values (e.g. the order code will
always be ``PREVIEW``). pretix plugins will not be triggered, so some special behavior might be missing as well.
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -1050,6 +1090,42 @@ Order state operations
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist. :statuscode 404: The requested order does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/reactivate/
Reactivates a canceled order. This will set the order to pending or paid state. Only possible if all products are
still available.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/reactivate/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"code": "ABC12",
"status": "n",
...
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param code: The ``code`` field of the order to modify
:statuscode 200: no error
:statuscode 400: The order cannot be reactivated
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_pending/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_pending/
Marks a paid order as unpaid. Marks a paid order as unpaid.
@@ -1621,6 +1697,10 @@ Order payment endpoints
These endpoints have been added. These endpoints have been added.
.. versionchanged:: 3.6
Payments can now be created through the API.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
Returns a list of all payments for an order. Returns a list of all payments for an order.
@@ -1829,6 +1909,61 @@ Order payment endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or payment does not exist. :statuscode 404: The requested order or payment does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
Creates a new payment.
Be careful with the ``info`` parameter: You can pass a nested JSON object that will be set as the internal ``info``
value of the payment object that will be created. How this value is handled is up to the payment provider and you
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
charge will be created), this is just informative in case you *handled the payment already*.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"state": "confirmed",
"amount": "23.00",
"payment_date": "2017-12-04T12:13:12Z",
"info": {},
"provider": "banktransfer"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"local_id": 1,
"state": "confirmed",
"amount": "23.00",
"created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer"
}
:param organizer: The ``slug`` field of the organizer to access
:param event: The ``slug`` field of the event to access
:param order: The ``code`` field of the order to access
:statuscode 201: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
Order refund endpoints Order refund endpoints
---------------------- ----------------------
@@ -1947,7 +2082,8 @@ Order refund endpoints
"payment": 1, "payment": 1,
"execution_date": null, "execution_date": null,
"provider": "manual", "provider": "manual",
"mark_canceled": false "mark_canceled": false,
"mark_pending": true
} }
**Example response**: **Example response**:

671
doc/api/resources/teams.rst Normal file
View File

@@ -0,0 +1,671 @@
.. spelling:: fullname
.. _`rest-teams`:
Teams
=====
.. warning:: Unlike our user interface, the team API **does** allow you to lock yourself out by deleting or modifying
the team your user or API key belongs to. Be careful around here!
Team resource
-------------
The team resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the team
name string Team name
all_events boolean Whether this team has access to all events
limit_events list List of event slugs this team has access to
can_create_events boolean
can_change_teams boolean
can_change_organizer_settings boolean
can_manage_gift_cards boolean
can_change_event_settings boolean
can_change_items boolean
can_view_orders boolean
can_change_orders boolean
can_view_vouchers boolean
can_change_vouchers boolean
===================================== ========================== =======================================================
Team member resource
--------------------
The team member resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the user
email string The user's email address
fullname string The user's full name (or ``null``)
require_2fa boolean Whether this user uses two-factor-authentication
===================================== ========================== =======================================================
Team invite resource
--------------------
The team invite resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the invite
email string The invitee's email address
===================================== ========================== =======================================================
Team API token resource
-----------------------
The team API token resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the invite
name string Name of this API token
active boolean Whether this API token is active (can never be set to
``true`` again once ``false``)
token string The actual API token. Will only be sent back during
token creation.
===================================== ========================== =======================================================
Team endpoints
--------------
.. http:get:: /api/v1/organizers/(organizer)/teams/
Returns a list of all teams within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/teams/ 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": "Admin team",
"all_events": true,
"limit_events": [],
"can_create_events": true,
...
}
]
}
: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)/teams/(id)/
Returns information on one team, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/teams/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Admin team",
"all_events": true,
"limit_events": [],
"can_create_events": true,
...
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the team 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)/teams/
Creates a new team
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/teams/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "Admin team",
"all_events": true,
"limit_events": [],
"can_create_events": true,
...
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 2,
"name": "Admin team",
"all_events": true,
"limit_events": [],
"can_create_events": true,
...
}
:param organizer: The ``slug`` field of the organizer to create a team for
:statuscode 201: no error
:statuscode 400: The team 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)/teams/(id)/
Update a team. 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.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/teams/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"can_create_events": true
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Admin team",
"all_events": true,
"limit_events": [],
"can_create_events": true,
...
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the team to modify
:statuscode 200: no error
:statuscode 400: The team 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)/teams/(id)/
Deletes a team.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/teams/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the team to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
Team member endpoints
---------------------
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/members/
Returns a list of all members of a team.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/teams/1/members/ 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,
"fullname": "John Doe",
"email": "john@example.com",
"require_2fa": true
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:param team: The ``id`` field of the team 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.
:statuscode 404: The requested team does not exist
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/members/(id)/
Returns information on one team member, identified by their ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/teams/1/members/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"fullname": "John Doe",
"email": "john@example.com",
"require_2fa": true
}
:param organizer: The ``slug`` field of the organizer to fetch
:param team: The ``id`` field of the team to fetch
:param id: The ``id`` field of the member 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.
:statuscode 404: The requested team or member does not exist
.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/members/(id)/
Removes a member from the team.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/teams/1/members/1/ HTTP/1.1
Host: pretix.eu
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
:param organizer: The ``slug`` field of the organizer to modify
:param team: The ``id`` field of the team to modify
:param id: The ``id`` field of the member to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
:statuscode 404: The requested team or member does not exist
Team invite endpoints
---------------------
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/invites/
Returns a list of all invitations to a team.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/teams/1/invites/ 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,
"email": "john@example.com"
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:param team: The ``id`` field of the team 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.
:statuscode 404: The requested team does not exist
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/invites/(id)/
Returns information on one invite, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/teams/1/invites/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"email": "john@example.org"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param team: The ``id`` field of the team to fetch
:param id: The ``id`` field of the invite 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.
:statuscode 404: The requested team or invite does not exist
.. http:post:: /api/v1/organizers/(organizer)/teams/(team)/invites/
Invites someone into the team. Note that if the user already has a pretix account, you will receive a response without
an ``id`` and instead of an invite being created, the user will be directly added to the team.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/teams/1/invites/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"email": "mark@example.org"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"email": "mark@example.org"
}
:param organizer: The ``slug`` field of the organizer to modify
:param team: The ``id`` field of the team to modify
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
:statuscode 404: The requested team does not exist
.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/invites/(id)/
Revokes an invite.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/teams/1/invites/1/ HTTP/1.1
Host: pretix.eu
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
:param organizer: The ``slug`` field of the organizer to modify
:param team: The ``id`` field of the team to modify
:param id: The ``id`` field of the invite to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
:statuscode 404: The requested team or invite does not exist
Team API token endpoints
------------------------
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/tokens/
Returns a list of all API tokens of a team.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/teams/1/tokens/ 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,
"active": true,
"name": "Test token"
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:param team: The ``id`` field of the team 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.
:statuscode 404: The requested team does not exist
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/tokens/(id)/
Returns information on one token, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/teams/1/tokens/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"active": true,
"name": "Test token"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param team: The ``id`` field of the team to fetch
:param id: The ``id`` field of the token 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.
:statuscode 404: The requested team or token does not exist
.. http:post:: /api/v1/organizers/(organizer)/teams/(team)/tokens/
Creates a new token.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/teams/1/tokens/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"name": "New token"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 2,
"name": "New token",
"active": true,
"token": "",
}
:param organizer: The ``slug`` field of the organizer to modify
:param team: The ``id`` field of the team to create a token for
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
:statuscode 404: The requested team does not exist
.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/tokens/(id)/
Disables a token.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/teams/1/tokens/1/ HTTP/1.1
Host: pretix.eu
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "My token",
"active": false
}
:param organizer: The ``slug`` field of the organizer to modify
:param team: The ``id`` field of the team to modify
:param id: The ``id`` field of the token to delete
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
:statuscode 404: The requested team or token does not exist

View File

@@ -66,7 +66,7 @@ event-related views, there is also a signal that allows you to add the view to t
from django.urls import resolve, reverse from django.urls import resolve, reverse
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.control.signals import nav_event from pretix.control.signals import nav_event

View File

@@ -20,17 +20,24 @@ Order events
There are multiple signals that will be sent out in the ordering cycle: There are multiple signals that will be sent out in the ordering cycle:
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text :members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
Check-ins
"""""""""
.. automodule:: pretix.base.signals
:members: checkin_created
Frontend Frontend
-------- --------
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, item_description :members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:members: order_info, order_meta_from_request :members: order_info, order_info_top, order_meta_from_request
Request flow Request flow
"""""""""""" """"""""""""
@@ -81,3 +88,9 @@ Ticket designs
.. automodule:: pretix.plugins.ticketoutputpdf.signals .. automodule:: pretix.plugins.ticketoutputpdf.signals
:members: override_layout :members: override_layout
API
---
.. automodule:: pretix.base.signals
:members: validate_event_settings, api_event_settings_fields

View File

@@ -114,6 +114,8 @@ The provider class
.. automethod:: api_payment_details .. automethod:: api_payment_details
.. automethod:: matching_id
.. automethod:: shred_payment_info .. automethod:: shred_payment_info
.. automethod:: cancel_payment .. automethod:: cancel_payment

View File

@@ -46,6 +46,9 @@ name string The human-readable name of your plugin
author string Your name author string Your name
version string A human-readable version code of your plugin version string A human-readable version code of your plugin
description string A more verbose description of what your plugin does. description string A more verbose description of what your plugin does.
category string Category of a plugin. Either one of ``"FEATURE"``, ``"PAYMENT"``,
``"INTEGRATION"``, ``"CUSTOMIZATION"``, ``"FORMAT"``, or ``"API"``,
or any other string.
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated. visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
for an event by system administrators / superusers. for an event by system administrators / superusers.
@@ -58,7 +61,7 @@ A working example would be::
from pretix.base.plugins import PluginConfig from pretix.base.plugins import PluginConfig
except ImportError: except ImportError:
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!") raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
class PaypalApp(PluginConfig): class PaypalApp(PluginConfig):
@@ -69,6 +72,7 @@ A working example would be::
name = _("PayPal") name = _("PayPal")
author = _("the pretix team") author = _("the pretix team")
version = '1.0.0' version = '1.0.0'
category = 'PAYMENT
visible = True visible = True
restricted = False restricted = False
description = _("This plugin allows you to receive payments via PayPal") description = _("This plugin allows you to receive payments via PayPal")

View File

@@ -69,7 +69,7 @@ We now need a way to translate the action codes like ``pretix.event.changed`` in
strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple
implementation could look like:: implementation could look like::
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
from pretix.base.signals import logentry_display from pretix.base.signals import logentry_display
@receiver(signal=logentry_display) @receiver(signal=logentry_display)

224
doc/plugins/campaigns.rst Normal file
View File

@@ -0,0 +1,224 @@
Campaigns
=========
The campaigns plugin provides a HTTP API that allows you to create new campaigns.
Resource description
--------------------
The campaign resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal campaign ID
code string The URL component of the campaign, e.g. with code ``BAR``
the campaign URL would to be ``https://<server>/<organizer>/<event>/c/BAR/``.
This value needs to be *globally unique* and we do not
recommend setting it manually. If you omit it, a random
value will be chosen.
description string An internal, human-readable name of the campaign.
external_target string An URL to redirect to from the tracking link. To redirect to
the ticket shop, use an empty string.
order_count integer Number of orders tracked on this campaign (read-only)
click_count integer Number of clicks tracked on this campaign (read-only)
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/campaigns/
Returns a list of all campaigns configured for an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/campaigns/ 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,
"code": "wZnL11fjq",
"description": "Facebook",
"external_target": "",
"order_count:" 0,
"click_count:" 0
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/campaigns/(id)/
Returns information on one campaign, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/campaigns/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"code": "wZnL11fjq",
"description": "Facebook",
"external_target": "",
"order_count:" 0,
"click_count:" 0
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the campaign to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/campaign does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/campaigns/
Create a new campaign.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/campaigns/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
{
"description": "Twitter"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 2,
"code": "IfVJQzSBL",
"description": "Twitter",
"external_target": "",
"order_count:" 0,
"click_count:" 0
}
:param organizer: The ``slug`` field of the organizer to create a campaign for
:param event: The ``slug`` field of the event to create a campaign for
:statuscode 201: no error
:statuscode 400: The campaign 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 campaigns.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/campaigns/(id)/
Update a campaign. 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.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/campaigns/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 34
{
"external_target": "https://mywebsite.com"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 2,
"code": "IfVJQzSBL",
"description": "Twitter",
"external_target": "https://mywebsite.com",
"order_count:" 0,
"click_count:" 0
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the campaign to modify
:statuscode 200: no error
:statuscode 400: The campaign could not be modified due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/campaign does not exist **or** you have no permission to change it.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/campaigns/(id)/
Delete a campaign and all associated data.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/campaigns/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the campaign to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/campaign does not exist **or** you have no permission to change it

277
doc/plugins/digital.rst Normal file
View File

@@ -0,0 +1,277 @@
Digital content
===============
The digital content plugin provides a HTTP API that allows you to create new digital content for your ticket holders,
such as live streams, videos, or material downloads.
Resource description
--------------------
The digital content resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal content ID
title multi-lingual string The content title (required)
content_type string The type of content, valid values are ``webinar``, ``video``, ``livestream``, ``link``, ``file``
url string The location of the digital content
description multi-lingual string A public description of the item. May contain Markdown
syntax and is not required.
available_from datetime The first date time at which this content will be shown
(or ``null``).
available_until datetime The last date time at which this content will b e shown
(or ``null``).
all_products boolean If ``true``, the content is available to all buyers of tickets for this event. The ``limit_products`` field is ignored in this case.
limit_products list of integers List of product/item IDs. This content is only shown to buyers of these ticket types.
position integer An integer, used for sorting
subevent integer Date in an event series this content should be shown for. Should be ``null`` if this is not an event series or if this should be shown to all customers.
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/
Returns a list of all digital content configured for an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/ 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,
"subevent": null,
"title": {
"en": "Concert livestream"
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"description": {
"en": "Watch our event live here on YouTube!"
},
"all_products": true,
"limit_products": [],
"available_from": "2020-03-22T23:00:00Z",
"available_until": null,
"position": 1
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/(id)/
Returns information on one content item, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"subevent": null,
"title": {
"en": "Concert livestream"
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"description": {
"en": "Watch our event live here on YouTube!"
},
"all_products": true,
"limit_products": [],
"available_from": "2020-03-22T23:00:00Z",
"available_until": null,
"position": 1
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the content to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/content does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/
Create a new digital content.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
{
"subevent": null,
"title": {
"en": "Concert livestream"
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"description": {
"en": "Watch our event live here on YouTube!"
},
"all_products": true,
"limit_products": [],
"available_from": "2020-03-22T23:00:00Z",
"available_until": null,
"position": 1
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 2,
"subevent": null,
"title": {
"en": "Concert livestream"
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"description": {
"en": "Watch our event live here on YouTube!"
},
"all_products": true,
"limit_products": [],
"available_from": "2020-03-22T23:00:00Z",
"available_until": null,
"position": 1
}
:param organizer: The ``slug`` field of the organizer to create new content for
:param event: The ``slug`` field of the event to create new content for
:statuscode 201: no error
:statuscode 400: The content 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 digital contents.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/(id)/
Update a content. 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.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 34
{
"url": "https://mywebsite.com"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 2,
"subevent": null,
"title": {
"en": "Concert livestream"
},
"content_type": "link",
"url": "https://mywebsite.com",
"description": {
"en": "Watch our event live here on YouTube!"
},
"all_products": true,
"limit_products": [],
"available_from": "2020-03-22T23:00:00Z",
"available_until": null,
"position": 1
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the content to modify
:statuscode 200: no error
:statuscode 400: The content could not be modified due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/content does not exist **or** you have no permission to change it.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/(id)/
Delete a digital content.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the content to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/content does not exist **or** you have no permission to change it

View File

@@ -14,3 +14,5 @@ If you want to **create** a plugin, please go to the
banktransfer banktransfer
ticketoutputpdf ticketoutputpdf
badges badges
campaigns
digital

View File

@@ -1,8 +1,9 @@
-r ../src/requirements.txt -r ../src/requirements.txt
sphinx==1.6.* sphinx==2.3.*
sphinx-rtd-theme sphinx-rtd-theme
sphinxcontrib-httpdomain sphinxcontrib-httpdomain
sphinxcontrib-images sphinxcontrib-images
sphinxcontrib-spelling sphinxcontrib-spelling
pygments-markdown-lexer
# See https://github.com/rfk/pyenchant/pull/130 # See https://github.com/rfk/pyenchant/pull/130
git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant

View File

@@ -103,6 +103,7 @@ regex
renderer renderer
renderers renderers
reportlab reportlab
reseller
SaaS SaaS
scalability scalability
screenshot screenshot
@@ -110,9 +111,10 @@ scss
searchable searchable
selectable selectable
serializable serializable
serializers serializer
serializers serializers
sexualized sexualized
SQL
startup startup
stdout stdout
stylesheet stylesheet
@@ -139,6 +141,7 @@ untrusted
uptime uptime
username username
url url
validator
versa versa
versioning versioning
viewable viewable

View File

@@ -114,6 +114,17 @@ If you want to disable voucher input in the widget, you can pass the ``disable-v
<pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget> <pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget>
Filtering products
------------------
You can filter the products shown in the widget by passing in a list of product IDs::
<pretix-widget event="https://pretix.eu/demo/democon/" items="23,42"></pretix-widget>
Alternatively, you can select one or more categories to be shown::
<pretix-widget event="https://pretix.eu/demo/democon/" categories="12,25"></pretix-widget>
Multi-event selection Multi-event selection
--------------------- ---------------------
@@ -183,6 +194,24 @@ Just as the widget, the button supports the optional attributes ``voucher`` and
You can style the button using the ``pretix-button`` CSS class. You can style the button using the ``pretix-button`` CSS class.
Dynamically opening the widget
------------------------------
You can get the behavior of the pretix Button without a button at all, so you can trigger it from your own code in
response to a user action. Usually, this will open an overlay with your ticket shop, however in some cases, such as
missing HTTPS encryption on your case or a really small screen (mobile), it will open a new tab instead of an overlay.
Therefore, make sure you call this *in direct response to a user action*, otherwise most browser will block it as an
unwanted pop-up.
.. js:function:: window.PretixWidget.open(target_url [, voucher [, subevent [, items, [, widget_data [, skip_ssl_check ]]]]])
:param string target_url: The URL of the ticket shop.
:param string voucher: A voucher code to be pre-selected, or ``null``.
:param string subevent: A subevent to be pre-selected, or ``null``.
:param array items: A collection of items to be put in the cart, of the form ``[{"item": "item_3", "count": 1}, {"item": "variation_5_6", "count": 4}]``
:param object widget_data: Additional data to be passed to the shop, see below.
:param boolean skip_ssl_check: Whether to ignore the check for HTTPS. Only to be used during development.
Dynamically loading the widget Dynamically loading the widget
------------------------------ ------------------------------
@@ -238,7 +267,8 @@ with that information::
data-question-L9G8NG9M="Foobar"> data-question-L9G8NG9M="Foobar">
</pretix-widget> </pretix-widget>
This works for the pretix Button as well. Currently, the following attributes are understood by pretix itself: This works for the pretix Button as well, if you also specify a product.
Currently, the following attributes are understood by pretix itself:
* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled). * ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
@@ -303,4 +333,8 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work
fully if you configured a redis server. fully if you configured a redis server.
.. versionchanged:: 3.6
Dynamically opening the widget has been added in pretix 3.6.
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/

View File

@@ -40,6 +40,24 @@ If you created a product and it doesn't show up, please follow the following ste
6. If the sale period has not started yet or is already over, check the "Show items outside presale period" setting of 6. If the sale period has not started yet or is already over, check the "Show items outside presale period" setting of
your event. your event.
Can I have different payment deadlines for different payment methods?
---------------------------------------------------------------------
No. We do not think it makes a lot of sense, for a number of reasons. First of all we believe it is not very
customer-friendly. You might for example want to configure a 1-day deadline for credit card payments and 2 weeks for
bank transfers. However, think for example of a customer who wants to pay by card and then the payment fails because
the bank locked the card or refused the payment. The customer now needs to worry about not getting their ticket, or
needs to create a new order with a different payment method. A payment deadline is a guarantee to your customer to hold
the ticket if it is paid for within a certain time frame. If you give a two-week guarantee to some of your customers,
why not to others?
There are some other issues with it as well. pretix allows customers to switch payment methods as long as their payment
has not been started or if it has failed. For example, a customer who selected bank transfer can later switch to credit
card if they haven't sent the money yet, or a customer with a failed credit card payment can switch to a different
method without creating a new order. If payment deadlines were dependent on the payment method, switching back and
forth could either allow someone to extend their deadline forever, or render someones order invalid by moving the date
back in the past.
How can I revert a check-in? How can I revert a check-in?
---------------------------- ----------------------------

View File

@@ -14,30 +14,23 @@ and with pretix, you can do this. On this page, you find out the necessary steps
With the pretix.eu hosted service With the pretix.eu hosted service
--------------------------------- ---------------------------------
Step 1: DNS Configuration Go to "Organizers" in the backend and select your organizer account. Then, go to "Settings" and "Custom Domain".
#########################
This page will show you instructions on how to set up your own domain. Basically, it works like this:
Go to the website of the provider you registered your domain name with. Look for the "DNS" settings page in their Go to the website of the provider you registered your domain name with. Look for the "DNS" settings page in their
interface. Unfortunately, we can't tell you exactly how that is named and how it looks, since it is different for every interface. Unfortunately, we can't tell you exactly how that is named and how it looks, since it is different for every
domain provider. domain provider.
Use this interface to add a new subdomain record, e.g. ``tickets`` of the type ``CNAME`` (might also be called "alias"). Use this interface to add a new subdomain record, e.g. ``tickets`` of the type ``CNAME`` (might also be called "alias").
The value of the record should be ``www.pretix.eu``. The value of the record should be the one shown on the "Custom Domain" page in pretix' backend.
Step 2: Wait for the DNS entry to propagate
###########################################
Submit your changes and wait a bit, it can regularly take up to three hours for DNS changes to propagate to the caches Submit your changes and wait a bit, it can regularly take up to three hours for DNS changes to propagate to the caches
of all DNS servers. You can try checking by accessing your new subdomain, ``http://tickets.awesomepartycorp.com``. of all DNS servers. You can try checking by accessing your new subdomain, ``http://tickets.awesomepartycorp.com``.
If DNS was changed successfully, you should see a SSL certificate error. If you ignore the error and access the page If DNS was changed successfully, you should see a SSL certificate error. If you ignore the error and access the page
anyways, you should get a pretix-themed error page with the headline "Unknown domain". anyways, you should get a pretix-themed error page with the headline "Unknown domain".
Step 3: Tell us Now, tell us about your domain on the "Custom Domain" page to get started.
###############
Write an email to support@pretix.eu, naming your new domain and your organizer account. We will then generate a SSL
certificate for you (for free!) and configure the domain.
With a custom pretix installation With a custom pretix installation
--------------------------------- ---------------------------------

View File

@@ -1 +1 @@
__version__ = "3.5.0" __version__ = "3.8.0"

View File

@@ -3,7 +3,7 @@ from datetime import timedelta
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from oauth2_provider.generators import ( from oauth2_provider.generators import (
generate_client_id, generate_client_secret, generate_client_id, generate_client_secret,
) )

View File

@@ -2,7 +2,7 @@ from datetime import timedelta
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy from django.utils.translation import gettext_lazy
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@@ -56,7 +56,7 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent'))) else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
if len(new_quotas) == 0: if len(new_quotas) == 0:
raise ValidationError( raise ValidationError(
ugettext_lazy('The product "{}" is not assigned to a quota.').format( gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(validated_data.get('item')) str(validated_data.get('item'))
) )
) )
@@ -64,8 +64,8 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
avail = quota.availability() avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1): if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
raise ValidationError( raise ValidationError(
ugettext_lazy('There is not enough quota available on quota "{}" to perform ' gettext_lazy('There is not enough quota available on quota "{}" to perform '
'the operation.').format( 'the operation.').format(
quota.name quota.name
) )
) )
@@ -88,7 +88,7 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
else: else:
validated_data['seat'] = seat validated_data['seat'] = seat
if not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web')): if not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web')):
raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)) raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
elif seated: elif seated:
raise ValidationError('The specified product requires to choose a seat.') raise ValidationError('The specified product requires to choose a seat.')

View File

@@ -1,4 +1,4 @@
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError

View File

@@ -2,9 +2,11 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
from django_countries.serializers import CountryFieldMixin from django_countries.serializers import CountryFieldMixin
from hierarkey.proxy import HierarkeyProxy
from pytz import common_timezones from pytz import common_timezones
from rest_framework import serializers
from rest_framework.fields import ChoiceField, Field from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
@@ -15,6 +17,8 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.services.seating import ( from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change, SeatProtected, generate_seats, validate_plan_change,
) )
from pretix.base.settings import DEFAULTS, validate_settings
from pretix.base.signals import api_event_settings_fields
class MetaDataField(Field): class MetaDataField(Field):
@@ -30,6 +34,19 @@ class MetaDataField(Field):
} }
class MetaPropertyField(Field):
def to_representation(self, value):
return {
v.name: v.default for v in value.item_meta_properties.all()
}
def to_internal_value(self, data):
return {
'item_meta_properties': data
}
class SeatCategoryMappingField(Field): class SeatCategoryMappingField(Field):
def to_representation(self, value): def to_representation(self, value):
@@ -73,6 +90,7 @@ class TimeZoneField(ChoiceField):
class EventSerializer(I18nAwareModelSerializer): class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
item_meta_properties = MetaPropertyField(required=False, source='*')
plugins = PluginsField(required=False, source='*') plugins = PluginsField(required=False, source='*')
seat_category_mapping = SeatCategoryMappingField(source='*', required=False) seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones]) timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones])
@@ -82,7 +100,7 @@ class EventSerializer(I18nAwareModelSerializer):
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from', fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start', 'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
'plugins', 'seat_category_mapping', 'timezone') 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -127,6 +145,12 @@ class EventSerializer(I18nAwareModelSerializer):
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
return value return value
@cached_property
def item_meta_props(self):
return {
p.name: p for p in self.context['request'].event.item_meta_properties.all()
}
def validate_seating_plan(self, value): def validate_seating_plan(self, value):
if value and value.organizer != self.context['request'].organizer: if value and value.organizer != self.context['request'].organizer:
raise ValidationError('Invalid seating plan.') raise ValidationError('Invalid seating plan.')
@@ -138,8 +162,11 @@ class EventSerializer(I18nAwareModelSerializer):
return value return value
def validate_seat_category_mapping(self, value): def validate_seat_category_mapping(self, value):
if value and value['seat_category_mapping'] and (not self.instance or not self.instance.pk): if not self.instance or not self.instance.pk:
raise ValidationError('You cannot specify seat category mappings on event creation.') if value and value['seat_category_mapping']:
raise ValidationError('You cannot specify seat category mappings on event creation.')
else:
return {'seat_category_mapping': {}}
item_cache = {i.pk: i for i in self.instance.items.all()} item_cache = {i.pk: i for i in self.instance.items.all()}
result = {} result = {}
for k, item in value['seat_category_mapping'].items(): for k, item in value['seat_category_mapping'].items():
@@ -165,6 +192,7 @@ class EventSerializer(I18nAwareModelSerializer):
@transaction.atomic @transaction.atomic
def create(self, validated_data): def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
item_meta_properties = validated_data.pop('item_meta_properties', None)
validated_data.pop('seat_category_mapping', None) validated_data.pop('seat_category_mapping', None)
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(',')) plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
tz = validated_data.pop('timezone', None) tz = validated_data.pop('timezone', None)
@@ -181,6 +209,15 @@ class EventSerializer(I18nAwareModelSerializer):
value=value value=value
) )
# Item Meta properties
if item_meta_properties is not None:
for key, value in item_meta_properties.items():
event.item_meta_properties.create(
name=key,
default=value,
event=event
)
# Seats # Seats
if event.seating_plan: if event.seating_plan:
generate_seats(event, None, event.seating_plan, {}) generate_seats(event, None, event.seating_plan, {})
@@ -195,6 +232,7 @@ class EventSerializer(I18nAwareModelSerializer):
@transaction.atomic @transaction.atomic
def update(self, instance, validated_data): def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
item_meta_properties = validated_data.pop('item_meta_properties', None)
plugins = validated_data.pop('plugins', None) plugins = validated_data.pop('plugins', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None) seat_category_mapping = validated_data.pop('seat_category_mapping', None)
tz = validated_data.pop('timezone', None) tz = validated_data.pop('timezone', None)
@@ -221,6 +259,26 @@ class EventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data: if prop.name not in meta_data:
current_object.delete() current_object.delete()
# Item Meta properties
if item_meta_properties is not None:
current = [imp for imp in event.item_meta_properties.all()]
for key, value in item_meta_properties.items():
prop = self.item_meta_props.get(key)
if prop in current:
prop.default = value
prop.save()
else:
prop = event.item_meta_properties.create(
name=key,
default=value,
event=event
)
current.append(prop)
for prop in current:
if prop.name not in list(item_meta_properties.keys()):
prop.delete()
# Seats # Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None): if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
current_mappings = { current_mappings = {
@@ -466,3 +524,138 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta: class Meta:
model = TaxRule model = TaxRule
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country') fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
class EventSettingsSerializer(serializers.Serializer):
default_fields = [
'imprint_url',
'checkout_email_helptext',
'presale_has_ended_text',
'voucher_explanation_text',
'banner_text',
'banner_text_bottom',
'show_dates_on_frontpage',
'show_date_to',
'show_times',
'show_items_outside_presale_period',
'display_net_prices',
'presale_start_show_date',
'locales',
'locale',
'last_order_modification_date',
'show_quota_left',
'waiting_list_enabled',
'waiting_list_hours',
'waiting_list_auto',
'max_items_per_order',
'reservation_time',
'contact_mail',
'show_variations_expanded',
'hide_sold_out',
'meta_noindex',
'redirect_to_checkout_directly',
'frontpage_subevent_ordering',
'frontpage_text',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_company_asked',
'attendee_company_required',
'confirm_text',
'order_email_asked_twice',
'payment_term_days',
'payment_term_last',
'payment_term_weekdays',
'payment_term_expire_automatically',
'payment_term_accept_late',
'payment_explanation',
'ticket_download',
'ticket_download_date',
'ticket_download_addons',
'ticket_download_nonadm',
'ticket_download_pending',
'mail_prefix',
'mail_from',
'mail_from_name',
'mail_attach_ical',
'invoice_address_asked',
'invoice_address_required',
'invoice_address_vatid',
'invoice_address_company_required',
'invoice_address_beneficiary',
'invoice_address_custom_field',
'invoice_name_required',
'invoice_address_not_asked_free',
'invoice_show_payments',
'invoice_reissue_after_modify',
'invoice_include_free',
'invoice_generate',
'invoice_numbers_consecutive',
'invoice_numbers_prefix',
'invoice_numbers_prefix_cancellations',
'invoice_attendee_name',
'invoice_include_expire_date',
'invoice_address_explanation_text',
'invoice_email_attachment',
'invoice_address_from_name',
'invoice_address_from',
'invoice_address_from_zipcode',
'invoice_address_from_city',
'invoice_address_from_country',
'invoice_address_from_tax_id',
'invoice_address_from_vat_id',
'invoice_introductory_text',
'invoice_additional_text',
'invoice_footer_text',
'cancel_allow_user',
'cancel_allow_user_until',
'cancel_allow_user_paid',
'cancel_allow_user_paid_until',
'cancel_allow_user_paid_keep',
'cancel_allow_user_paid_keep_fees',
'cancel_allow_user_paid_keep_percentage',
'cancel_allow_user_paid_adjust_fees',
'cancel_allow_user_paid_adjust_fees_explanation',
'cancel_allow_user_paid_refund_as_giftcard',
'cancel_allow_user_paid_require_approval',
]
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
for fname in self.default_fields:
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
kwargs.setdefault('required', False)
kwargs.setdefault('allow_null', True)
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
if 'serializer_class' not in DEFAULTS[fname]:
raise ValidationError('{} has no serializer class'.format(fname))
f = DEFAULTS[fname]['serializer_class'](
**kwargs
)
f._label = form_kwargs.get('label', fname)
f._help_text = form_kwargs.get('help_text')
self.fields[fname] = f
for recv, resp in api_event_settings_fields.send(sender=self.event):
for fname, field in resp.items():
field.required = False
self.fields[fname] = field
def update(self, instance: HierarkeyProxy, validated_data):
for attr, value in validated_data.items():
if value is None:
instance.delete(attr)
elif instance.get(attr, as_type=type(value)) != value:
instance.set(attr, value)
return instance
def validate(self, data):
data = super().validate(data)
settings_dict = self.instance.freeze()
settings_dict.update(data)
validate_settings(self.event, settings_dict)
return data

View File

@@ -0,0 +1,29 @@
from collections import OrderedDict
from rest_framework import serializers
def remove_duplicates_from_list(data):
return list(OrderedDict.fromkeys(data))
class ListMultipleChoiceField(serializers.MultipleChoiceField):
def to_internal_value(self, data):
if isinstance(data, str) or not hasattr(data, '__iter__'):
self.fail('not_a_list', input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail('empty')
internal_value_data = [
super(serializers.MultipleChoiceField, self).to_internal_value(item)
for item in data
]
return remove_duplicates_from_list(internal_value_data)
def to_representation(self, value):
representation_data = [
self.choice_strings_to_values.get(str(item), item) for item in value
]
return remove_duplicates_from_list(representation_data)

View File

@@ -2,13 +2,15 @@ from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.utils.translation import ugettext_lazy as _ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import ( from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
QuestionOption, Quota, Question, QuestionOption, Quota,
) )
@@ -110,6 +112,7 @@ class ItemSerializer(I18nAwareModelSerializer):
bundles = InlineItemBundleSerializer(many=True, required=False) bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True) tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*')
class Meta: class Meta:
model = Item model = Item
@@ -119,7 +122,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard') 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data')
read_only_fields = ('has_variations', 'picture') read_only_fields = ('has_variations', 'picture')
def validate(self, data): def validate(self, data):
@@ -167,18 +170,65 @@ class ItemSerializer(I18nAwareModelSerializer):
ItemAddOn.clean_max_min_count(addon_data['max_count'], addon_data['min_count']) ItemAddOn.clean_max_min_count(addon_data['max_count'], addon_data['min_count'])
return value return value
@cached_property
def item_meta_properties(self):
return {
p.name: p for p in self.context['request'].event.item_meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
@transaction.atomic @transaction.atomic
def create(self, validated_data): def create(self, validated_data):
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {} variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {} addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {} bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
item = Item.objects.create(**validated_data) item = Item.objects.create(**validated_data)
for variation_data in variations_data: for variation_data in variations_data:
ItemVariation.objects.create(item=item, **variation_data) ItemVariation.objects.create(item=item, **variation_data)
for addon_data in addons_data: for addon_data in addons_data:
ItemAddOn.objects.create(base_item=item, **addon_data) ItemAddOn.objects.create(base_item=item, **addon_data)
for bundle_data in bundles_data: for bundle_data in bundles_data:
ItemBundle.objects.create(base_item=item, **bundle_data) ItemBundle.objects.create(base_item=item, **bundle_data)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
ItemMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
item=item
)
return item
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
item = super().update(instance, validated_data)
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in item.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.item_meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
item.meta_values.create(
property=self.item_meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
return item return item
@@ -237,8 +287,8 @@ class QuestionSerializer(I18nAwareModelSerializer):
if value: if value:
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE): if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.') raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
if value == self.instance: if value == self.instance:
raise ValidationError('A question cannot depend on itself.') raise ValidationError('A question cannot depend on itself.')
return value return value
def validate(self, data): def validate(self, data):

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
import pycountry import pycountry
from django.db.models import F, Q from django.db.models import F, Q
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy from django.utils.translation import gettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@@ -25,6 +25,7 @@ from pretix.base.models.orders import (
) )
from pretix.base.pdf import get_variables from pretix.base.pdf import get_variables
from pretix.base.services.cart import error_messages from pretix.base.services.cart import error_messages
from pretix.base.services.locking import NoLockManager
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs
@@ -38,7 +39,7 @@ class CompatibleCountryField(serializers.Field):
def to_representation(self, instance: InvoiceAddress): def to_representation(self, instance: InvoiceAddress):
if instance.country: if instance.country:
return str(instance.country) return str(instance.country)
else: elif hasattr(instance, 'country_old'):
return instance.country_old return instance.country_old
@@ -96,6 +97,11 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
return [o.identifier for o in instance.options.all()] return [o.identifier for o in instance.options.all()]
class AnswerQuestionOptionsField(serializers.Field):
def to_representation(self, instance: QuestionAnswer):
return [o.pk for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer): class InlineSeatSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
@@ -106,6 +112,7 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
class AnswerSerializer(I18nAwareModelSerializer): class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
options = AnswerQuestionOptionsField(source='*', read_only=True)
class Meta: class Meta:
model = QuestionAnswer model = QuestionAnswer
@@ -189,6 +196,11 @@ class PdfDataSerializer(serializers.Field):
for k, v in ev._cached_meta_data.items(): for k, v in ev._cached_meta_data.items():
res['meta:' + k] = v res['meta:' + k] = v
if not hasattr(instance.item, '_cached_meta_data'):
instance.item._cached_meta_data = instance.item.meta_data
for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + k] = v
return res return res
@@ -199,10 +211,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field='code', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*') pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True) seat = InlineSeatSerializer(read_only=True)
country = CompatibleCountryField(source='*')
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled') 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
@@ -504,12 +518,22 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
max_digits=10) max_digits=10)
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(), voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
required=False, allow_null=True) required=False, allow_null=True)
country = CompatibleCountryField(source='*')
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher') 'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for k, v in self.fields.items():
if k in ('company', 'street', 'zipcode', 'city', 'country', 'state'):
v.required = False
v.allow_blank = True
v.allow_null = True
def validate_secret(self, secret): def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
raise ValidationError( raise ValidationError(
@@ -564,6 +588,24 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
) )
if data.get('attendee_name_parts') and '_scheme' not in data.get('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 data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country')):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data return data
@@ -580,6 +622,28 @@ class CompatibleJSONField(serializers.JSONField):
return value return value
class WrappedList:
def __init__(self, data):
self._data = data
def all(self):
return self._data
class WrappedModel:
def __init__(self, model):
self._wrapped = model
def __getattr__(self, item):
return getattr(self._wrapped, item)
def save(self, *args, **kwargs):
raise NotImplementedError
def delete(self, *args, **kwargs):
raise NotImplementedError
class OrderCreateSerializer(I18nAwareModelSerializer): class OrderCreateSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer(required=False) invoice_address = InvoiceAddressSerializer(required=False)
positions = OrderPositionCreateSerializer(many=True, required=True) positions = OrderPositionCreateSerializer(many=True, required=True)
@@ -600,6 +664,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
force = serializers.BooleanField(default=False, required=False) force = serializers.BooleanField(default=False, required=False)
payment_date = serializers.DateTimeField(required=False, allow_null=True) payment_date = serializers.DateTimeField(required=False, allow_null=True)
send_mail = serializers.BooleanField(default=False, required=False) send_mail = serializers.BooleanField(default=False, required=False)
simulate = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -609,7 +674,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
model = Order model = Order
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_mail') 'force', 'send_mail', 'simulate')
def validate_payment_provider(self, pp): def validate_payment_provider(self, pp):
if pp is None: if pp is None:
@@ -701,6 +766,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_info = validated_data.pop('payment_info', '{}') payment_info = validated_data.pop('payment_info', '{}')
payment_date = validated_data.pop('payment_date', now()) payment_date = validated_data.pop('payment_date', now())
force = validated_data.pop('force', False) force = validated_data.pop('force', False)
simulate = validated_data.pop('simulate', False)
self._send_mail = validated_data.pop('send_mail', False) self._send_mail = validated_data.pop('send_mail', False)
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
@@ -714,7 +780,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else: else:
ia = None ia = None
with self.context['event'].lock() as now_dt: lockfn = self.context['event'].lock
if simulate:
lockfn = NoLockManager
with lockfn() as now_dt:
free_seats = set() free_seats = set()
seats_seen = set() seats_seen = set()
consume_carts = validated_data.pop('consume_carts', []) consume_carts = validated_data.pop('consume_carts', [])
@@ -823,7 +892,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else: else:
pos_data['seat'] = seat pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat in seats_seen: if (seat not in free_seats and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat in seats_seen:
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)] errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
seats_seen.add(seat) seats_seen.add(seat)
elif seated: elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.'] errs[i]['seat'] = ['The specified product requires to choose a seat.']
@@ -838,7 +907,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if pos_data.get('variation') if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))) else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
if len(new_quotas) == 0: if len(new_quotas) == 0:
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format( errs[i]['item'] = [gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(pos_data.get('item')) str(pos_data.get('item'))
)] )]
else: else:
@@ -850,7 +919,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
quota_avail_cache[quota][1] -= 1 quota_avail_cache[quota][1] -= 1
if quota_avail_cache[quota][1] < 0: if quota_avail_cache[quota][1] < 0:
errs[i]['item'] = [ errs[i]['item'] = [
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format( gettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
quota.name quota.name
) )
] ]
@@ -864,11 +933,20 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.set_expires(subevents=[p.get('subevent') for p in positions_data]) order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.meta_info = "{}" order.meta_info = "{}"
order.total = Decimal('0.00') order.total = Decimal('0.00')
order.save() if simulate:
order = WrappedModel(order)
order.last_modified = now()
order.code = 'PREVIEW'
else:
order.save()
if ia: if ia:
ia.order = order if not simulate:
ia.save() ia.order = order
ia.save()
else:
order.invoice_address = ia
ia.last_modified = now()
pos_map = {} pos_map = {}
for pos_data in positions_data: for pos_data in positions_data:
@@ -880,7 +958,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
'_legacy': attendee_name '_legacy': attendee_name
} }
pos = OrderPosition(**pos_data) pos = OrderPosition(**pos_data)
pos.order = order if simulate:
pos.order = order._wrapped
else:
pos.order = order
if addon_to: if addon_to:
pos.addon_to = pos_map[addon_to] pos.addon_to = pos_map[addon_to]
@@ -911,19 +992,33 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
invoice_address=ia, invoice_address=ia,
).gross ).gross
if pos.voucher: if simulate:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1) pos = WrappedModel(pos)
pos.save() pos.id = 0
answers = []
for answ_data in answers_data:
options = answ_data.pop('options', [])
answ = WrappedModel(QuestionAnswer(**answ_data))
answ.options = WrappedList(options)
answers.append(answ)
pos.answers = answers
pos.pseudonymization_id = "PREVIEW"
else:
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
pos.save()
for answ_data in answers_data:
options = answ_data.pop('options', [])
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
pos_map[pos.positionid] = pos pos_map[pos.positionid] = pos
for answ_data in answers_data:
options = answ_data.pop('options', [])
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
for cp in delete_cps: if not simulate:
cp.delete() for cp in delete_cps:
cp.delete()
order.total = sum([p.price for p in order.positions.all()]) order.total = sum([p.price for p in pos_map.values()])
fees = []
for fee_data in fees_data: for fee_data in fees_data:
is_percentage = fee_data.pop('_treat_value_as_percentage', False) is_percentage = fee_data.pop('_treat_value_as_percentage', False)
if is_percentage: if is_percentage:
@@ -955,17 +1050,26 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
fee_data['tax_rule'] = tr fee_data['tax_rule'] = tr
fee_data['value'] = val fee_data['value'] = val
f = OrderFee(**fee_data) f = OrderFee(**fee_data)
f.order = order f.order = order._wrapped if simulate else order
f._calculate_tax() f._calculate_tax()
f.save() fees.append(f)
if not simulate:
f.save()
else: else:
f = OrderFee(**fee_data) f = OrderFee(**fee_data)
f.order = order f.order = order._wrapped if simulate else order
f._calculate_tax() f._calculate_tax()
f.save() fees.append(f)
if not simulate:
f.save()
order.total += sum([f.value for f in order.fees.all()]) order.total += sum([f.value for f in fees])
order.save(update_fields=['total']) if simulate:
order.fees = fees
order.positions = pos_map.values()
return order # ignore payments
else:
order.save(update_fields=['total'])
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider: if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
payment_provider = 'free' payment_provider = 'free'
@@ -1034,6 +1138,20 @@ class InvoiceSerializer(I18nAwareModelSerializer):
'internal_reference') 'internal_reference')
class OrderPaymentCreateSerializer(I18nAwareModelSerializer):
provider = serializers.CharField(required=True, allow_null=False, allow_blank=False)
info = CompatibleJSONField(required=False)
class Meta:
model = OrderPayment
fields = ('state', 'amount', 'payment_date', 'provider', 'info')
def create(self, validated_data):
order = OrderPayment(order=self.context['order'], **validated_data)
order.save()
return order
class OrderRefundCreateSerializer(I18nAwareModelSerializer): class OrderRefundCreateSerializer(I18nAwareModelSerializer):
payment = serializers.IntegerField(required=False, allow_null=True) payment = serializers.IntegerField(required=False, allow_null=True)
provider = serializers.CharField(required=True, allow_null=False, allow_blank=False) provider = serializers.CharField(required=True, allow_null=False, allow_blank=False)

View File

@@ -1,14 +1,19 @@
from decimal import Decimal from decimal import Decimal
from django.db.models import Q from django.db.models import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import get_language, gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField from pretix.api.serializers.order import CompatibleJSONField
from pretix.base.models import GiftCard, Organizer, SeatingPlan from pretix.base.auth import get_auth_backends
from pretix.base.models import (
GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail
from pretix.helpers.urls import build_absolute_uri
class OrganizerSerializer(I18nAwareModelSerializer): class OrganizerSerializer(I18nAwareModelSerializer):
@@ -36,16 +41,129 @@ class GiftCardSerializer(I18nAwareModelSerializer):
qs = GiftCard.objects.filter( qs = GiftCard.objects.filter(
secret=s secret=s
).filter( ).filter(
Q(issuer=self.context["organizer"]) | Q(issuer__gift_card_collector_acceptance__collector=self.context["organizer"]) Q(issuer=self.context["organizer"]) | Q(
issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
) )
if self.instance: if self.instance:
qs = qs.exclude(pk=self.instance.pk) qs = qs.exclude(pk=self.instance.pk)
if qs.exists(): if qs.exists():
raise ValidationError( raise ValidationError(
{'secret': _('A gift card with the same secret already exists in your or an affiliated organizer account.')} {'secret': _(
'A gift card with the same secret already exists in your or an affiliated organizer account.')}
) )
return data return data
class Meta: class Meta:
model = GiftCard model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode') fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode')
class EventSlugField(serializers.SlugRelatedField):
def get_queryset(self):
return self.context['organizer'].events.all()
class TeamSerializer(serializers.ModelSerializer):
limit_events = EventSlugField(slug_field='slug', many=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Meta:
model = Team
fields = (
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers'
)
def validate(self, data):
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('limit_events') and full_data.get('all_events'):
raise ValidationError('Do not set both limit_events and all_events.')
return data
class TeamInviteSerializer(serializers.ModelSerializer):
class Meta:
model = TeamInvite
fields = (
'id', 'email'
)
def _send_invite(self, instance):
try:
mail(
instance.email,
_('pretix account invitation'),
'pretixcontrol/email/invitation.txt',
{
'user': self,
'organizer': self.context['organizer'].name,
'team': instance.team.name,
'url': build_absolute_uri('control:auth.invite', kwargs={
'token': instance.token
})
},
event=None,
locale=get_language() # TODO: expose?
)
except SendMailException:
pass # Already logged
def create(self, validated_data):
if 'email' in validated_data:
try:
user = User.objects.get(email__iexact=validated_data['email'])
except User.DoesNotExist:
if self.context['team'].invites.filter(email__iexact=validated_data['email']).exists():
raise ValidationError(_('This user already has been invited for this team.'))
if 'native' not in get_auth_backends():
raise ValidationError('Users need to have a pretix account before they can be invited.')
invite = self.context['team'].invites.create(email=validated_data['email'])
self._send_invite(invite)
invite.team.log_action(
'pretix.team.invite.created',
data={
'email': validated_data['email']
},
**self.context['log_kwargs']
)
return invite
else:
if self.context['team'].members.filter(pk=user.pk).exists():
raise ValidationError(_('This user already has permissions for this team.'))
self.context['team'].members.add(user)
self.context['team'].log_action(
'pretix.team.member.added',
data={
'email': user.email,
'user': user.pk,
},
**self.context['log_kwargs']
)
return TeamInvite(email=user.email)
else:
raise ValidationError('No email address given.')
class TeamAPITokenSerializer(serializers.ModelSerializer):
active = serializers.BooleanField(default=True, read_only=True)
class Meta:
model = TeamAPIToken
fields = (
'id', 'name', 'active'
)
class TeamMemberSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
'id', 'email', 'fullname', 'require_2fa'
)

View File

@@ -7,8 +7,8 @@ from rest_framework import routers
from pretix.api.views import cart from pretix.api.views import cart
from .views import ( from .views import (
checkin, device, event, item, oauth, order, organizer, user, voucher, checkin, device, event, item, oauth, order, organizer, user, version,
waitinglist, webhooks, voucher, waitinglist, webhooks,
) )
router = routers.DefaultRouter() router = routers.DefaultRouter()
@@ -20,6 +20,12 @@ orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet) orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet) orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
orga_router.register(r'giftcards', organizer.GiftCardViewSet) orga_router.register(r'giftcards', organizer.GiftCardViewSet)
orga_router.register(r'teams', organizer.TeamViewSet)
team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet)
team_router.register(r'invites', organizer.TeamInviteViewSet)
team_router.register(r'tokens', organizer.TeamAPITokenViewSet)
event_router = routers.DefaultRouter() event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet) event_router.register(r'subevents', event.SubEventViewSet)
@@ -61,7 +67,10 @@ for app in apps.get_app_configs():
urlpatterns = [ urlpatterns = [
url(r'^', include(router.urls)), url(r'^', include(router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)), url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/settings/$', event.EventSettingsView.as_view(),
name="event.settings"),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)), url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/teams/(?P<team>[^/]+)/', include(team_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)), url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/questions/(?P<question>[^/]+)/', url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/questions/(?P<question>[^/]+)/',
include(question_router.urls)), include(question_router.urls)),
@@ -76,4 +85,5 @@ urlpatterns = [
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"), url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"), url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
url(r"^me$", user.MeView.as_view(), name="user.me"), url(r"^me$", user.MeView.as_view(), name="user.me"),
url(r"^version$", version.VersionView.as_view(), name="version"),
] ]

View File

@@ -4,13 +4,14 @@ from django.db.models import ProtectedError, Q
from django.utils.timezone import now from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import filters, viewsets from rest_framework import filters, views, viewsets
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from pretix.api.auth.permission import EventCRUDPermission from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.serializers.event import ( from pretix.api.serializers.event import (
CloneEventSerializer, EventSerializer, SubEventSerializer, CloneEventSerializer, EventSerializer, EventSettingsSerializer,
TaxRuleSerializer, SubEventSerializer, TaxRuleSerializer,
) )
from pretix.api.views import ConditionalListView from pretix.api.views import ConditionalListView
from pretix.base.models import ( from pretix.base.models import (
@@ -333,3 +334,33 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
) )
super().perform_destroy(instance) super().perform_destroy(instance)
class EventSettingsView(views.APIView):
permission = 'can_change_event_settings'
def get(self, request, *args, **kwargs):
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
if 'explain' in request.GET:
return Response({
fname: {
'value': s.data[fname],
'label': getattr(field, '_label', fname),
'help_text': getattr(field, '_help_text', None)
} for fname, field in s.fields.items()
})
return Response(s.data)
def patch(self, request, *wargs, **kwargs):
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
event=request.event)
s.is_valid(raise_exception=True)
with transaction.atomic():
s.save()
self.request.event.log_action(
'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={
k: v for k, v in s.validated_data.items()
}
)
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
return Response(s.data)

View File

@@ -2,7 +2,7 @@ import logging
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
from oauth2_provider.exceptions import OAuthToolkitError from oauth2_provider.exceptions import OAuthToolkitError
from oauth2_provider.forms import AllowForm from oauth2_provider.forms import AllowForm
from oauth2_provider.views import ( from oauth2_provider.views import (

View File

@@ -9,7 +9,7 @@ from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse, HttpResponse from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import mixins, serializers, status, viewsets from rest_framework import mixins, serializers, status, viewsets
@@ -23,9 +23,10 @@ from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer, InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer, OrderPaymentSerializer, OrderPositionSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer, OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
PriceCalcSerializer,
) )
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
@@ -43,7 +44,7 @@ from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, _order_placed_email, OrderChangeManager, OrderError, _order_placed_email,
_order_placed_email_attendee, approve_order, cancel_order, deny_order, _order_placed_email_attendee, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded, extend_order, mark_order_expired, mark_order_refunded, reactivate_order,
) )
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
from pretix.base.services.tickets import generate from pretix.base.services.tickets import generate
@@ -260,6 +261,29 @@ class OrderViewSet(viewsets.ModelViewSet):
) )
return self.retrieve(request, [], **kwargs) return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
def reactivate(self, request, **kwargs):
order = self.get_object()
if order.status != Order.STATUS_CANCELED:
return Response(
{'detail': 'The order is not allowed to be reactivated.'},
status=status.HTTP_400_BAD_REQUEST
)
try:
reactivate_order(
order,
user=request.user if request.user.is_authenticated else None,
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
)
except OrderError as e:
return Response(
{'detail': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def approve(self, request, **kwargs): def approve(self, request, **kwargs):
send_mail = request.data.get('send_email', True) send_mail = request.data.get('send_email', True)
@@ -465,6 +489,9 @@ class OrderViewSet(viewsets.ModelViewSet):
send_mail = serializer._send_mail send_mail = serializer._send_mail
order = serializer.instance order = serializer.instance
serializer = OrderSerializer(order, context=serializer.context) serializer = OrderSerializer(order, context=serializer.context)
if not order.pk:
# Simulation
return Response(serializer.data, status=status.HTTP_201_CREATED)
order.log_action( order.log_action(
'pretix.event.order.placed', 'pretix.event.order.placed',
@@ -825,17 +852,62 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
raise ValidationError(str(e)) raise ValidationError(str(e))
class PaymentViewSet(viewsets.ReadOnlyModelViewSet): class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPaymentSerializer serializer_class = OrderPaymentSerializer
queryset = OrderPayment.objects.none() queryset = OrderPayment.objects.none()
permission = 'can_view_orders' permission = 'can_view_orders'
write_permission = 'can_change_orders' write_permission = 'can_change_orders'
lookup_field = 'local_id' lookup_field = 'local_id'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return ctx
def get_queryset(self): def get_queryset(self):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event) order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.payments.all() return order.payments.all()
def create(self, request, *args, **kwargs):
serializer = OrderPaymentCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic():
mark_confirmed = False
if serializer.validated_data['state'] == OrderPayment.PAYMENT_STATE_CONFIRMED:
serializer.validated_data['state'] = OrderPayment.PAYMENT_STATE_PENDING
mark_confirmed = True
self.perform_create(serializer)
r = serializer.instance
if mark_confirmed:
try:
r.confirm(
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
count_waitinglist=False,
force=request.data.get('force', False)
)
except Quota.QuotaExceededException:
pass
except SendMailException:
pass
serializer = OrderPaymentSerializer(r, context=serializer.context)
r.order.log_action(
'pretix.event.order.payment.started', {
'local_id': r.local_id,
'provider': r.provider,
},
user=request.user if request.user.is_authenticated else None,
auth=request.auth
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
serializer.save()
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def confirm(self, request, **kwargs): def confirm(self, request, **kwargs):
payment = self.get_object() payment = self.get_object()
@@ -1015,6 +1087,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
mark_refunded = request.data.pop('mark_refunded', False) mark_refunded = request.data.pop('mark_refunded', False)
else: else:
mark_refunded = request.data.pop('mark_canceled', False) mark_refunded = request.data.pop('mark_canceled', False)
mark_pending = request.data.pop('mark_pending', False)
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context()) serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
with transaction.atomic(): with transaction.atomic():
@@ -1031,11 +1104,23 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
auth=request.auth auth=request.auth
) )
if mark_refunded: if mark_refunded:
mark_order_refunded( try:
r.order, mark_order_refunded(
user=request.user if request.user.is_authenticated else None, r.order,
auth=(request.auth if request.auth else None), user=request.user if request.user.is_authenticated else None,
) auth=(request.auth if request.auth else None),
)
except OrderError as e:
raise ValidationError(str(e))
elif mark_pending:
if r.order.status == Order.STATUS_PAID and r.order.pending_sum > 0:
r.order.status = Order.STATUS_PENDING
r.order.set_expires(
now(),
r.order.event.subevents.filter(
id__in=r.order.positions.values_list('subevent_id', flat=True))
)
r.order.save(update_fields=['status', 'expires'])
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@@ -1,16 +1,26 @@
from decimal import Decimal from decimal import Decimal
import django_filters
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import filters, serializers, status, viewsets from rest_framework import filters, serializers, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import ( from pretix.api.serializers.organizer import (
GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer, GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer,
TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer,
TeamSerializer,
)
from pretix.base.models import (
GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
) )
from pretix.base.models import GiftCard, Organizer, SeatingPlan
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
@@ -48,13 +58,14 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
write_permission = 'can_change_organizer_settings' write_permission = 'can_change_organizer_settings'
def get_queryset(self): def get_queryset(self):
return self.request.organizer.seating_plans.all() return self.request.organizer.seating_plans.order_by('name')
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer ctx['organizer'] = self.request.organizer
return ctx return ctx
@transaction.atomic()
def perform_create(self, serializer): def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer) inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action( self.request.organizer.log_action(
@@ -64,6 +75,7 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': inst.pk}) data=merge_dicts(self.request.data, {'id': inst.pk})
) )
@transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
if serializer.instance.events.exists() or serializer.instance.subevents.exists(): if serializer.instance.events.exists() or serializer.instance.subevents.exists():
raise PermissionDenied('This plan can not be changed while it is in use for an event.') raise PermissionDenied('This plan can not be changed while it is in use for an event.')
@@ -76,6 +88,7 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
) )
return inst return inst
@transaction.atomic()
def perform_destroy(self, instance): def perform_destroy(self, instance):
if instance.events.exists() or instance.subevents.exists(): if instance.events.exists() or instance.subevents.exists():
raise PermissionDenied('This plan can not be deleted while it is in use for an event.') raise PermissionDenied('This plan can not be deleted while it is in use for an event.')
@@ -88,14 +101,29 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
instance.delete() instance.delete()
with scopes_disabled():
class GiftCardFilter(FilterSet):
secret = django_filters.CharFilter(field_name='secret', lookup_expr='iexact')
class Meta:
model = GiftCard
fields = ['secret', 'testmode']
class GiftCardViewSet(viewsets.ModelViewSet): class GiftCardViewSet(viewsets.ModelViewSet):
serializer_class = GiftCardSerializer serializer_class = GiftCardSerializer
queryset = GiftCard.objects.none() queryset = GiftCard.objects.none()
permission = 'can_manage_gift_cards' permission = 'can_manage_gift_cards'
write_permission = 'can_manage_gift_cards' write_permission = 'can_manage_gift_cards'
filter_backends = (DjangoFilterBackend,)
filterset_class = GiftCardFilter
def get_queryset(self): def get_queryset(self):
return self.request.organizer.issued_gift_cards.all() if self.request.GET.get('include_accepted') == 'true':
qs = self.request.organizer.accepted_gift_cards
else:
qs = self.request.organizer.issued_gift_cards.all()
return qs
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
@@ -116,6 +144,8 @@ class GiftCardViewSet(viewsets.ModelViewSet):
@transaction.atomic() @transaction.atomic()
def perform_update(self, serializer): def perform_update(self, serializer):
if 'include_accepted' in self.request.GET:
raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.")
GiftCard.objects.select_for_update().get(pk=self.get_object().pk) GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
old_value = serializer.instance.value old_value = serializer.instance.value
value = serializer.validated_data.pop('value') value = serializer.validated_data.pop('value')
@@ -138,18 +168,187 @@ class GiftCardViewSet(viewsets.ModelViewSet):
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value( value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
request.data.get('value') request.data.get('value')
) )
text = serializers.CharField(allow_blank=True, allow_null=True).to_internal_value(
request.data.get('text', '')
)
if gc.value + value < Decimal('0.00'): if gc.value + value < Decimal('0.00'):
return Response({ return Response({
'value': ['The gift card does not have sufficient credit for this operation.'] 'value': ['The gift card does not have sufficient credit for this operation.']
}, status=status.HTTP_409_CONFLICT) }, status=status.HTTP_409_CONFLICT)
gc.transactions.create(value=value) gc.transactions.create(value=value, text=text)
gc.log_action( gc.log_action(
'pretix.giftcards.transaction.manual', 'pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={'value': value} data={'value': value, 'text': text}
) )
return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK) return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK)
def perform_destroy(self, instance): def perform_destroy(self, instance):
raise MethodNotAllowed("Gift cards cannot be deleted.") raise MethodNotAllowed("Gift cards cannot be deleted.")
class TeamViewSet(viewsets.ModelViewSet):
serializer_class = TeamSerializer
queryset = Team.objects.none()
permission = 'can_change_teams'
write_permission = 'can_change_teams'
def get_queryset(self):
return self.request.organizer.teams.order_by('pk')
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
inst.log_action(
'pretix.team.created',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save()
inst.log_action(
'pretix.team.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
return inst
def perform_destroy(self, instance):
instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth)
instance.delete()
class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = TeamMemberSerializer
queryset = User.objects.none()
permission = 'can_change_teams'
write_permission = 'can_change_teams'
@cached_property
def team(self):
return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team'))
def get_queryset(self):
return self.team.members.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
self.team.members.remove(instance)
self.team.log_action(
'pretix.team.member.removed', user=self.request.user, auth=self.request.auth, data={
'email': instance.email,
'user': instance.pk
}
)
class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = TeamInviteSerializer
queryset = TeamInvite.objects.none()
permission = 'can_change_teams'
write_permission = 'can_change_teams'
@cached_property
def team(self):
return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team'))
def get_queryset(self):
return self.team.invites.order_by('email')
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
ctx['team'] = self.team
ctx['log_kwargs'] = {
'user': self.request.user,
'auth': self.request.auth,
}
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
self.team.log_action(
'pretix.team.invite.deleted', user=self.request.user, auth=self.request.auth, data={
'email': instance.email,
}
)
instance.delete()
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(team=self.team)
class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = TeamAPITokenSerializer
queryset = TeamAPIToken.objects.none()
permission = 'can_change_teams'
write_permission = 'can_change_teams'
@cached_property
def team(self):
return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team'))
def get_queryset(self):
return self.team.tokens.order_by('name')
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
ctx['team'] = self.team
ctx['log_kwargs'] = {
'user': self.request.user,
'auth': self.request.auth,
}
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
instance.active = False
instance.save()
self.team.log_action(
'pretix.team.token.deleted', user=self.request.user, auth=self.request.auth, data={
'name': instance.name,
}
)
@transaction.atomic()
def perform_create(self, serializer):
instance = serializer.save(team=self.team)
self.team.log_action(
'pretix.team.token.created', auth=self.request.auth, user=self.request.user, data={
'name': instance.name,
'id': instance.pk
}
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
d = serializer.data
d['token'] = serializer.instance.token
return Response(d, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
self.perform_destroy(instance)
serializer = self.get_serializer_class()(instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_200_OK, headers=headers)

View File

@@ -0,0 +1,56 @@
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from packaging import version
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix import __version__
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.auth.token import TeamTokenAuthentication
def numeric_version(v):
# Converts a pretix version to a large int
# e.g. 30060001000
# |--------------------- Major version
# |-|------------------ Minor version
# |-|--------------- Patch version
# ||------------- Stage (10 dev, 20 alpha, 30 beta, 40 rc, 50 release, 60 post)
# ||----------- Stage version (number of dev/alpha/beta/rc/post release)
v = version.parse(v)
phases = {
'dev': 10,
'a': 20,
'b': 30,
'rc': 40,
'release': 50,
'post': 60
}
vnum = 0
if v.is_postrelease:
vnum += v.post
vnum += phases['post'] * 100
elif v.dev is not None:
vnum += v.dev
vnum += phases['dev'] * 100
elif v.is_prerelease and v.pre:
vnum += v.pre[0]
vnum += phases[v.pre[1]] * 100
else:
vnum += phases['release'] * 100
for i, part in enumerate(reversed(v.release)):
vnum += part * (1000 ** i) * 10000
return vnum
class VersionView(APIView):
authentication_classes = (
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
)
def get(self, request, format=None):
return Response({
'pretix': __version__,
'pretix_numeric': numeric_version(__version__),
})

View File

@@ -7,7 +7,7 @@ import requests
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from requests import RequestException from requests import RequestException
@@ -125,6 +125,10 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.canceled', 'pretix.event.order.canceled',
_('Order canceled'), _('Order canceled'),
), ),
ParametrizedOrderWebhookEvent(
'pretix.event.order.reactivated',
_('Order reactivated'),
),
ParametrizedOrderWebhookEvent( ParametrizedOrderWebhookEvent(
'pretix.event.order.expired', 'pretix.event.order.expired',
_('Order expired'), _('Order expired'),

View File

@@ -1,5 +1,4 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
class PretixBaseConfig(AppConfig): class PretixBaseConfig(AppConfig):
@@ -14,6 +13,7 @@ class PretixBaseConfig(AppConfig):
from . import notifications # NOQA from . import notifications # NOQA
from . import email # NOQA from . import email # NOQA
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from django.conf import settings
try: try:
from .celery_app import app as celery_app # NOQA from .celery_app import app as celery_app # NOQA

View File

@@ -85,6 +85,16 @@ class BaseAuthBackend:
""" """
return return
def get_next_url(self, request):
"""
This method will be called after a successful login to determine the next URL. Pretix in general uses the
``'next'`` query parameter. However, external authentication methods could use custom attributes with hardcoded
names for security purposes. For example, OAuth uses ``'state'`` for keeping track of application state.
"""
if "next" in request.GET:
return request.GET.get("next")
return None
class NativeAuthBackend(BaseAuthBackend): class NativeAuthBackend(BaseAuthBackend):
identifier = 'native' identifier = 'native'

View File

@@ -2,7 +2,7 @@ import logging
from collections import OrderedDict from collections import OrderedDict
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.signals import register_sales_channels from pretix.base.signals import register_sales_channels

View File

@@ -9,7 +9,7 @@ from django.core.mail.backends.smtp import EmailBackend
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import get_language, ugettext_lazy as _ from django.utils.translation import get_language, gettext_lazy as _
from inlinestyler.utils import inline_css from inlinestyler.utils import inline_css
from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber
@@ -136,15 +136,22 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
class ClassicMailRenderer(TemplateBasedMailRenderer): class ClassicMailRenderer(TemplateBasedMailRenderer):
verbose_name = _('pretix default') verbose_name = _('Default')
identifier = 'classic' identifier = 'classic'
thumbnail_filename = 'pretixbase/email/thumb.png' thumbnail_filename = 'pretixbase/email/thumb.png'
template_name = 'pretixbase/email/plainwrapper.html' template_name = 'pretixbase/email/plainwrapper.html'
class UnembellishedMailRenderer(TemplateBasedMailRenderer):
verbose_name = _('Simple with logo')
identifier = 'simple_logo'
thumbnail_filename = 'pretixbase/email/thumb_simple_logo.png'
template_name = 'pretixbase/email/simple_logo.html'
@receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers") @receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers")
def base_renderers(sender, **kwargs): def base_renderers(sender, **kwargs):
return [ClassicMailRenderer] return [ClassicMailRenderer, UnembellishedMailRenderer]
class BaseMailTextPlaceholder: class BaseMailTextPlaceholder:
@@ -260,6 +267,10 @@ def base_placeholders(sender, **kwargs):
SimpleFunctionalMailTextPlaceholder( SimpleFunctionalMailTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name 'event', ['event'], lambda event: event.name, lambda event: event.name
), ),
SimpleFunctionalMailTextPlaceholder(
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
lambda event_or_subevent: event_or_subevent.name
),
SimpleFunctionalMailTextPlaceholder( SimpleFunctionalMailTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug 'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
), ),
@@ -272,6 +283,11 @@ def base_placeholders(sender, **kwargs):
SimpleFunctionalMailTextPlaceholder( SimpleFunctionalMailTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency 'currency', ['event'], lambda event: event.currency, lambda event: event.currency
), ),
SimpleFunctionalMailTextPlaceholder(
'refund_amount', ['event_or_subevent', 'refund_amount'],
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
),
SimpleFunctionalMailTextPlaceholder( SimpleFunctionalMailTextPlaceholder(
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total, 'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
event.currency), event.currency),

View File

@@ -1,11 +1,13 @@
import io import io
import tempfile import tempfile
from collections import OrderedDict from collections import OrderedDict
from decimal import Decimal
from typing import Tuple from typing import Tuple
from defusedcsv import csv from defusedcsv import csv
from django import forms from django import forms
from django.utils.translation import ugettext, ugettext_lazy as _ from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.cell.cell import KNOWN_TYPES from openpyxl.cell.cell import KNOWN_TYPES
@@ -117,12 +119,20 @@ class ListExporter(BaseExporter):
if output_file: if output_file:
writer = csv.writer(output_file, **kwargs) writer = csv.writer(output_file, **kwargs)
for line in self.iterate_list(form_data): for line in self.iterate_list(form_data):
line = [
localize(f) if isinstance(f, Decimal) else f
for f in line
]
writer.writerow(line) writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', None return self.get_filename() + '.csv', 'text/csv', None
else: else:
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, **kwargs) writer = csv.writer(output, **kwargs)
for line in self.iterate_list(form_data): for line in self.iterate_list(form_data):
line = [
localize(f) if isinstance(f, Decimal) else f
for f in line
]
writer.writerow(line) writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8") return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
@@ -170,9 +180,9 @@ class MultiSheetListExporter(ListExporter):
] ]
for s, l in self.sheets: for s, l in self.sheets:
choices += [ choices += [
(s + ':default', str(l) + ' ' + ugettext('CSV (with commas)')), (s + ':default', str(l) + ' ' + gettext('CSV (with commas)')),
(s + ':excel', str(l) + ' ' + ugettext('CSV (Excel-style)')), (s + ':excel', str(l) + ' ' + gettext('CSV (Excel-style)')),
(s + ':semicolon', str(l) + ' ' + ugettext('CSV (with semicolons)')), (s + ':semicolon', str(l) + ' ' + gettext('CSV (with semicolons)')),
] ]
ff = OrderedDict( ff = OrderedDict(
[ [
@@ -196,12 +206,20 @@ class MultiSheetListExporter(ListExporter):
if output_file: if output_file:
writer = csv.writer(output_file, **kwargs) writer = csv.writer(output_file, **kwargs)
for line in self.iterate_sheet(form_data, sheet): for line in self.iterate_sheet(form_data, sheet):
line = [
localize(f) if isinstance(f, Decimal) else f
for f in line
]
writer.writerow(line) writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', None return self.get_filename() + '.csv', 'text/csv', None
else: else:
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, **kwargs) writer = csv.writer(output, **kwargs)
for line in self.iterate_sheet(form_data, sheet): for line in self.iterate_sheet(form_data, sheet):
line = [
localize(f) if isinstance(f, Decimal) else f
for f in line
]
writer.writerow(line) writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8") return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")

View File

@@ -5,7 +5,7 @@ from zipfile import ZipFile
from django import forms from django import forms
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.models import QuestionAnswer from pretix.base.models import QuestionAnswer

View File

@@ -6,7 +6,7 @@ import dateutil
from django import forms from django import forms
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext, ugettext_lazy from django.utils.translation import gettext, gettext_lazy
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Invoice, OrderPayment from pretix.base.models import Invoice, OrderPayment
@@ -79,7 +79,7 @@ class DekodiNREIExporter(BaseExporter):
payments.append({ payments.append({
'PTID': '5', 'PTID': '5',
'PTN': 'Lastschrift', 'PTN': 'Lastschrift',
'PTNo4': ugettext('Event ticket {event}-{code}').format( 'PTNo4': gettext('Event ticket {event}-{code}').format(
event=self.event.slug.upper(), event=self.event.slug.upper(),
code=invoice.order.code code=invoice.order.code
), ),
@@ -199,19 +199,19 @@ class DekodiNREIExporter(BaseExporter):
[ [
('date_from', ('date_from',
forms.DateField( forms.DateField(
label=ugettext_lazy('Start date'), label=gettext_lazy('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}), widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False, required=False,
help_text=ugettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does ' help_text=gettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
'not always correspond to the order or payment date.') 'not always correspond to the order or payment date.')
)), )),
('date_to', ('date_to',
forms.DateField( forms.DateField(
label=ugettext_lazy('End date'), label=gettext_lazy('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}), widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False, required=False,
help_text=ugettext_lazy('Only include invoices issued on or before this date. Note that the invoice date ' help_text=gettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
'does not always correspond to the order or payment date.') 'does not always correspond to the order or payment date.')
)), )),
] ]
) )

View File

@@ -7,7 +7,7 @@ import dateutil.parser
from django import forms from django import forms
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.models import OrderPayment from pretix.base.models import OrderPayment

View File

@@ -2,7 +2,7 @@ from collections import OrderedDict
from django import forms from django import forms
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.models import OrderPosition from pretix.base.models import OrderPosition

View File

@@ -3,13 +3,15 @@ from decimal import Decimal
import pytz import pytz
from django import forms from django import forms
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum from django.db.models import (
Count, DateTimeField, F, IntegerField, Max, OuterRef, Subquery, Sum,
)
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format, localize from django.utils.formats import date_format
from django.utils.translation import pgettext, ugettext as _, ugettext_lazy from django.utils.translation import gettext as _, gettext_lazy, pgettext
from pretix.base.models import ( from pretix.base.models import (
InvoiceAddress, InvoiceLine, Order, OrderPosition, Question, GiftCard, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question,
) )
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -20,7 +22,7 @@ from ..signals import register_data_exporters
class OrderListExporter(MultiSheetListExporter): class OrderListExporter(MultiSheetListExporter):
identifier = 'orderlist' identifier = 'orderlist'
verbose_name = ugettext_lazy('Order data') verbose_name = gettext_lazy('Order data')
@property @property
def sheets(self): def sheets(self):
@@ -80,8 +82,12 @@ class OrderListExporter(MultiSheetListExporter):
'm' 'm'
).order_by() ).order_by()
s = OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(k=Count('id')).values('k')
qs = self.event.orders.annotate( qs = self.event.orders.annotate(
payment_date=Subquery(p_date, output_field=DateTimeField()) payment_date=Subquery(p_date, output_field=DateTimeField()),
pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address').prefetch_related('invoices') ).select_related('invoice_address').prefetch_related('invoices')
if form_data['paid_only']: if form_data['paid_only']:
qs = qs.filter(status=Order.STATUS_PAID) qs = qs.filter(status=Order.STATUS_PAID)
@@ -111,6 +117,7 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Sales channel')) headers.append(_('Sales channel'))
headers.append(_('Requires special attention')) headers.append(_('Requires special attention'))
headers.append(_('Comment')) headers.append(_('Comment'))
headers.append(_('Positions'))
yield headers yield headers
@@ -134,7 +141,7 @@ class OrderListExporter(MultiSheetListExporter):
for order in qs.order_by('datetime'): for order in qs.order_by('datetime'):
row = [ row = [
order.code, order.code,
localize(order.total), order.total,
order.get_status_display(), order.get_status_display(),
order.email, order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'), order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
@@ -163,7 +170,7 @@ class OrderListExporter(MultiSheetListExporter):
row += [ row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '', order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')), full_fee_sum_cache.get(order.id) or Decimal('0.00'),
order.locale, order.locale,
] ]
@@ -173,16 +180,19 @@ class OrderListExporter(MultiSheetListExporter):
{'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')}) {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
row += [ row += [
localize(taxrate_values['grosssum'] + fee_taxrate_values['grosssum']), taxrate_values['grosssum'] + fee_taxrate_values['grosssum'],
localize(taxrate_values['grosssum'] - taxrate_values['taxsum'] (
+ fee_taxrate_values['grosssum'] - fee_taxrate_values['taxsum']), taxrate_values['grosssum'] - taxrate_values['taxsum'] +
localize(taxrate_values['taxsum'] + fee_taxrate_values['taxsum']), fee_taxrate_values['grosssum'] - fee_taxrate_values['taxsum']
),
taxrate_values['taxsum'] + fee_taxrate_values['taxsum'],
] ]
row.append(', '.join([i.number for i in order.invoices.all()])) row.append(', '.join([i.number for i in order.invoices.all()]))
row.append(order.sales_channel) row.append(order.sales_channel)
row.append(_('Yes') if order.checkin_attention else _('No')) row.append(_('Yes') if order.checkin_attention else _('No'))
row.append(order.comment or "") row.append(order.comment or "")
row.append(order.pcnt)
yield row yield row
def iterate_fees(self, form_data: dict): def iterate_fees(self, form_data: dict):
@@ -264,7 +274,7 @@ class OrderListExporter(MultiSheetListExporter):
'order', 'order__invoice_address', 'item', 'variation', 'order', 'order__invoice_address', 'item', 'variation',
'voucher', 'tax_rule' 'voucher', 'tax_rule'
).prefetch_related( ).prefetch_related(
'answers', 'answers__question' 'answers', 'answers__question', 'answers__options'
) )
if form_data['paid_only']: if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID) qs = qs.filter(order__status=Order.STATUS_PAID)
@@ -295,12 +305,25 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Attendee name') + ': ' + str(label)) headers.append(_('Attendee name') + ': ' + str(label))
headers += [ headers += [
_('Attendee email'), _('Attendee email'),
_('Company'),
_('Address'),
_('ZIP code'),
_('City'),
_('Country'),
pgettext('address', 'State'),
_('Voucher'), _('Voucher'),
_('Pseudonymization ID'), _('Pseudonymization ID'),
] ]
questions = list(self.event.questions.all()) questions = list(self.event.questions.all())
options = {}
for q in questions: for q in questions:
headers.append(str(q.question)) if q.type == Question.TYPE_CHOICE_MULTIPLE:
options[q.pk] = []
for o in q.options.all():
headers.append(str(q.question) + ' ' + str(o.answer))
options[q.pk].append(o)
else:
headers.append(str(q.question))
headers += [ headers += [
_('Company'), _('Company'),
_('Invoice address name'), _('Invoice address name'),
@@ -347,6 +370,12 @@ class OrderListExporter(MultiSheetListExporter):
) )
row += [ row += [
op.attendee_email, op.attendee_email,
op.company or '',
op.street or '',
op.zipcode or '',
op.city or '',
op.country if op.country else '',
op.state or '',
op.voucher.code if op.voucher else '', op.voucher.code if op.voucher else '',
op.pseudonymization_id, op.pseudonymization_id,
] ]
@@ -354,12 +383,19 @@ class OrderListExporter(MultiSheetListExporter):
for a in op.answers.all(): for a in op.answers.all():
# We do not want to localize Date, Time and Datetime question answers, as those can lead # We do not want to localize Date, Time and Datetime question answers, as those can lead
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French). # to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
if a.question.type in Question.UNLOCALIZED_TYPES: if a.question.type == Question.TYPE_CHOICE_MULTIPLE:
acache[a.question_id] = set(o.pk for o in a.options.all())
elif a.question.type in Question.UNLOCALIZED_TYPES:
acache[a.question_id] = a.answer acache[a.question_id] = a.answer
else: else:
acache[a.question_id] = str(a) acache[a.question_id] = str(a)
for q in questions: for q in questions:
row.append(acache.get(q.pk, '')) if q.type == Question.TYPE_CHOICE_MULTIPLE:
for o in options[q.pk]:
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
else:
row.append(acache.get(q.pk, ''))
try: try:
row += [ row += [
order.invoice_address.company, order.invoice_address.company,
@@ -390,7 +426,7 @@ class OrderListExporter(MultiSheetListExporter):
class PaymentListExporter(ListExporter): class PaymentListExporter(ListExporter):
identifier = 'paymentlist' identifier = 'paymentlist'
verbose_name = ugettext_lazy('Order payments and refunds') verbose_name = gettext_lazy('Order payments and refunds')
@property @property
def additional_form_fields(self): def additional_form_fields(self):
@@ -450,7 +486,7 @@ class PaymentListExporter(ListExporter):
d2, d2,
obj.get_state_display(), obj.get_state_display(),
obj.state, obj.state,
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)), obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
provider_names.get(obj.provider, obj.provider) provider_names.get(obj.provider, obj.provider)
] ]
yield row yield row
@@ -461,7 +497,7 @@ class PaymentListExporter(ListExporter):
class QuotaListExporter(ListExporter): class QuotaListExporter(ListExporter):
identifier = 'quotalist' identifier = 'quotalist'
verbose_name = ugettext_lazy('Quota availabilities') verbose_name = gettext_lazy('Quota availabilities')
def iterate_list(self, form_data): def iterate_list(self, form_data):
headers = [ headers = [
@@ -490,7 +526,7 @@ class QuotaListExporter(ListExporter):
class InvoiceDataExporter(MultiSheetListExporter): class InvoiceDataExporter(MultiSheetListExporter):
identifier = 'invoicedata' identifier = 'invoicedata'
verbose_name = ugettext_lazy('Invoice data') verbose_name = gettext_lazy('Invoice data')
@property @property
def sheets(self): def sheets(self):
@@ -531,10 +567,11 @@ class InvoiceDataExporter(MultiSheetListExporter):
_('Foreign currency rate'), _('Foreign currency rate'),
_('Total value (with taxes)'), _('Total value (with taxes)'),
_('Total value (without taxes)'), _('Total value (without taxes)'),
_('Payment matching IDs'),
] ]
qs = self.event.invoices.order_by('full_invoice_no').select_related( qs = self.event.invoices.order_by('full_invoice_no').select_related(
'order', 'refers' 'order', 'refers'
).annotate( ).prefetch_related('order__payments').annotate(
total_gross=Subquery( total_gross=Subquery(
InvoiceLine.objects.filter( InvoiceLine.objects.filter(
invoice=OuterRef('pk') invoice=OuterRef('pk')
@@ -551,6 +588,16 @@ class InvoiceDataExporter(MultiSheetListExporter):
) )
) )
for i in qs: for i in qs:
pmis = []
for p in i.order.payments.all():
if p.state in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_REFUNDED):
pprov = p.payment_provider
if pprov:
mid = pprov.matching_id(p)
if mid:
pmis.append(mid)
pmi = '\n'.join(pmis)
yield [ yield [
i.full_invoice_no, i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"), date_format(i.date, "SHORT_DATE_FORMAT"),
@@ -581,6 +628,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
i.foreign_currency_rate, i.foreign_currency_rate,
i.total_gross if i.total_gross else Decimal('0.00'), i.total_gross if i.total_gross else Decimal('0.00'),
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')), Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
pmi
] ]
elif sheet == 'lines': elif sheet == 'lines':
yield [ yield [
@@ -662,6 +710,45 @@ class InvoiceDataExporter(MultiSheetListExporter):
return '{}_invoices'.format(self.event.slug) return '{}_invoices'.format(self.event.slug)
class GiftcardRedemptionListExporter(ListExporter):
identifier = 'giftcardredemptionlist'
verbose_name = gettext_lazy('Giftcard Redemptions')
def iterate_list(self, form_data):
tz = pytz.timezone(self.event.settings.timezone)
payments = OrderPayment.objects.filter(
order__event=self.event,
provider='giftcard'
).order_by('created')
refunds = OrderRefund.objects.filter(
order__event=self.event,
provider='giftcard'
).order_by('created')
objs = sorted(list(payments) + list(refunds), key=lambda o: (o.order.code, o.created))
headers = [
_('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer')
]
yield headers
for obj in objs:
gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card'))
row = [
obj.order.code,
obj.full_id,
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
gc.secret,
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
gc.issuer
]
yield row
def get_filename(self):
return '{}_giftcardredemptions'.format(self.event.slug)
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist") @receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
def register_orderlist_exporter(sender, **kwargs): def register_orderlist_exporter(sender, **kwargs):
return OrderListExporter return OrderListExporter
@@ -680,3 +767,8 @@ def register_quotalist_exporter(sender, **kwargs):
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata") @receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
def register_invoicedata_exporter(sender, **kwargs): def register_invoicedata_exporter(sender, **kwargs):
return InvoiceDataExporter return InvoiceDataExporter
@receiver(register_data_exporters, dispatch_uid="exporter_giftcardredemptionlist")
def register_giftcardredemptionlist_exporter(sender, **kwargs):
return GiftcardRedemptionListExporter

View File

@@ -3,12 +3,10 @@ import logging
import i18nfield.forms import i18nfield.forms
from django import forms from django import forms
from django.forms.models import ModelFormMetaclass from django.forms.models import ModelFormMetaclass
from django.utils import six
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from formtools.wizard.views import SessionWizardView from formtools.wizard.views import SessionWizardView
from hierarkey.forms import HierarkeyForm from hierarkey.forms import HierarkeyForm
from pretix.base.models import Event
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from .validators import PlaceholderValidator # NOQA from .validators import PlaceholderValidator # NOQA
@@ -26,7 +24,7 @@ class BaseI18nModelForm(i18nfield.forms.BaseI18nModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class I18nModelForm(six.with_metaclass(ModelFormMetaclass, BaseI18nModelForm)): class I18nModelForm(BaseI18nModelForm, metaclass=ModelFormMetaclass):
pass pass
@@ -51,19 +49,33 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm): class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
auto_fields = []
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
from pretix.base.settings import DEFAULTS
self.obj = kwargs.get('obj', None) self.obj = kwargs.get('obj', None)
self.locales = self.obj.settings.get('locales') if self.obj else kwargs.pop('locales', None) self.locales = self.obj.settings.get('locales') if self.obj else kwargs.pop('locales', None)
kwargs['attribute_name'] = 'settings' kwargs['attribute_name'] = 'settings'
kwargs['locales'] = self.locales kwargs['locales'] = self.locales
kwargs['initial'] = self.obj.settings.freeze() kwargs['initial'] = self.obj.settings.freeze()
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for fname in self.auto_fields:
kwargs = DEFAULTS[fname].get('form_kwargs', {})
kwargs.setdefault('required', False)
field = DEFAULTS[fname]['form_class'](
**kwargs
)
if isinstance(field, i18nfield.forms.I18nFormField):
field.widget.enabled_locales = self.locales
self.fields[fname] = field
for k, f in self.fields.items(): for k, f in self.fields.items():
if isinstance(f, (RelativeDateTimeField, RelativeDateField)): if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
f.set_event(self.obj) f.set_event(self.obj)
def get_new_filename(self, name: str) -> str: def get_new_filename(self, name: str) -> str:
from pretix.base.models import Event
nonce = get_random_string(length=8) nonce = get_random_string(length=8)
if isinstance(self.obj, Event): if isinstance(self.obj, Event):
fname = '%s/%s/%s.%s.%s' % ( fname = '%s/%s/%s.%s.%s' % (

View File

@@ -3,9 +3,10 @@ from django.conf import settings
from django.contrib.auth.password_validation import ( from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password, password_validators_help_texts, validate_password,
) )
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.models import User from pretix.base.models import User
from pretix.helpers.dicts import move_to_end
class LoginForm(forms.Form): class LoginForm(forms.Form):
@@ -36,7 +37,7 @@ class LoginForm(forms.Form):
if not settings.PRETIX_LONG_SESSIONS or backend.url: if not settings.PRETIX_LONG_SESSIONS or backend.url:
del self.fields['keep_logged_in'] del self.fields['keep_logged_in']
else: else:
self.fields.move_to_end('keep_logged_in') move_to_end(self.fields, 'keep_logged_in')
def clean(self): def clean(self):
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required): if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):

View File

@@ -18,7 +18,7 @@ from django.forms import Select
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ( from django.utils.translation import (
get_language, pgettext_lazy, ugettext_lazy as _, get_language, gettext_lazy as _, pgettext_lazy,
) )
from django_countries import countries from django_countries import countries
from django_countries.fields import Country, CountryField from django_countries.fields import Country, CountryField
@@ -41,6 +41,7 @@ from pretix.base.settings import (
) )
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import SplitDateTimeField from pretix.control.forms import SplitDateTimeField
from pretix.helpers.countries import CachedCountries
from pretix.helpers.escapejson import escapejson_attr from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
@@ -214,6 +215,10 @@ def guess_country(event):
return country return country
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
class BaseQuestionsForm(forms.Form): class BaseQuestionsForm(forms.Form):
""" """
This form class is responsible for asking order-related questions. This includes This form class is responsible for asking order-related questions. This includes
@@ -241,7 +246,7 @@ class BaseQuestionsForm(forms.Form):
if item.admission and event.settings.attendee_names_asked: if item.admission and event.settings.attendee_names_asked:
self.fields['attendee_name_parts'] = NamePartsFormField( self.fields['attendee_name_parts'] = NamePartsFormField(
max_length=255, max_length=255,
required=event.settings.attendee_names_required, required=event.settings.attendee_names_required and not self.all_optional,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles, titles=event.settings.name_scheme_titles,
label=_('Attendee name'), label=_('Attendee name'),
@@ -249,7 +254,7 @@ class BaseQuestionsForm(forms.Form):
) )
if item.admission and event.settings.attendee_emails_asked: if item.admission and event.settings.attendee_emails_asked:
self.fields['attendee_email'] = forms.EmailField( self.fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required, required=event.settings.attendee_emails_required and not self.all_optional,
label=_('Attendee email'), label=_('Attendee email'),
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email), initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
widget=forms.EmailInput( widget=forms.EmailInput(
@@ -258,6 +263,75 @@ class BaseQuestionsForm(forms.Form):
} }
) )
) )
if item.admission and event.settings.attendee_company_asked:
self.fields['company'] = forms.CharField(
required=event.settings.attendee_company_required and not self.all_optional,
label=_('Company'),
initial=(cartpos.company if cartpos else orderpos.company),
)
if item.admission and event.settings.attendee_addresses_asked:
self.fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'),
widget=forms.Textarea(attrs={
'rows': 2,
'placeholder': _('Street and Number'),
'autocomplete': 'street-address'
}),
initial=(cartpos.street if cartpos else orderpos.street),
)
self.fields['zipcode'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('ZIP code'),
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
widget=forms.TextInput(attrs={
'autocomplete': 'postal-code',
}),
)
self.fields['city'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('City'),
initial=(cartpos.city if cartpos else orderpos.city),
widget=forms.TextInput(attrs={
'autocomplete': 'address-level2',
}),
)
country = (cartpos.country if cartpos else orderpos.country) or guess_country(event)
self.fields['country'] = CountryField(
countries=CachedCountries
).formfield(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Country'),
initial=country,
widget=forms.Select(attrs={
'autocomplete': 'country',
}),
)
c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
cc = None
if fprefix + 'country' in self.data:
cc = str(self.data[fprefix + 'country'])
elif country:
cc = str(country)
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
elif fprefix + 'state' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'state']
self.fields['state'] = forms.ChoiceField(
label=pgettext_lazy('address', 'State'),
required=False,
choices=c,
widget=forms.Select(attrs={
'autocomplete': 'address-level1',
}),
)
self.fields['state'].widget.is_required = True
for q in questions: for q in questions:
# Do we already have an answer? Provide it as the initial value # Do we already have an answer? Provide it as the initial value
@@ -309,12 +383,14 @@ class BaseQuestionsForm(forms.Form):
initial=initial.answer if initial else None, initial=initial.answer if initial else None,
) )
elif q.type == Question.TYPE_COUNTRYCODE: elif q.type == Question.TYPE_COUNTRYCODE:
field = CountryField().formfield( field = CountryField(
countries=CachedCountries
).formfield(
label=label, required=required, label=label, required=required,
help_text=help_text, help_text=help_text,
widget=forms.Select, widget=forms.Select,
empty_label='', empty_label='',
initial=initial.answer if initial else None, initial=initial.answer if initial else guess_country(event),
) )
elif q.type == Question.TYPE_CHOICE: elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField( field = forms.ModelChoiceField(
@@ -332,7 +408,7 @@ class BaseQuestionsForm(forms.Form):
label=label, required=required, label=label, required=required,
help_text=help_text, help_text=help_text,
to_field_name='identifier', to_field_name='identifier',
widget=forms.CheckboxSelectMultiple, widget=QuestionCheckboxSelectMultiple,
initial=initial.options.all() if initial else None, initial=initial.options.all() if initial else None,
) )
elif q.type == Question.TYPE_FILE: elif q.type == Question.TYPE_FILE:
@@ -419,6 +495,10 @@ class BaseQuestionsForm(forms.Form):
def clean(self): def clean(self):
d = super().clean() d = super().clean()
if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not d.get('state'):
self.add_error('state', _('This field is required.'))
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)} question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
def question_is_visible(parentid, qvals): def question_is_visible(parentid, qvals):
@@ -457,7 +537,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
class Meta: class Meta:
model = InvoiceAddress model = InvoiceAddress
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'state', fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'state',
'vat_id', 'internal_reference', 'beneficiary') 'vat_id', 'internal_reference', 'beneficiary', 'custom_field')
widgets = { widgets = {
'is_business': BusinessBooleanRadio, 'is_business': BusinessBooleanRadio,
'street': forms.Textarea(attrs={ 'street': forms.Textarea(attrs={
@@ -500,6 +580,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not event.settings.invoice_address_vatid: if not event.settings.invoice_address_vatid:
del self.fields['vat_id'] del self.fields['vat_id']
self.fields['country'].choices = CachedCountries()
c = [('', pgettext_lazy('address', 'Select state'))] c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = self.prefix + '-' if self.prefix else '' fprefix = self.prefix + '-' if self.prefix else ''
cc = None cc = None
@@ -561,6 +643,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not event.settings.invoice_address_beneficiary: if not event.settings.invoice_address_beneficiary:
del self.fields['beneficiary'] del self.fields['beneficiary']
if event.settings.invoice_address_custom_field:
self.fields['custom_field'].label = event.settings.invoice_address_custom_field
else:
del self.fields['custom_field']
for k, v in self.fields.items(): for k, v in self.fields.items():
if v.widget.attrs.get('autocomplete') or k == 'name_parts': if v.widget.attrs.get('autocomplete') or k == 'name_parts':
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '') v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')

View File

@@ -4,7 +4,7 @@ from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password, password_validators_help_texts, validate_password,
) )
from django.db.models import Q from django.db.models import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pytz import common_timezones from pytz import common_timezones
from pretix.base.models import User from pretix.base.models import User

View File

@@ -2,7 +2,7 @@ import re
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator from django.core.validators import BaseValidator
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString

View File

@@ -4,10 +4,7 @@ from django import forms
from django.utils.formats import get_format from django.utils.formats import get_format
from django.utils.functional import lazy from django.utils.functional import lazy
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.models import OrderPosition
from pretix.multidomain.urlreverse import eventreverse
class DatePickerWidget(forms.DateInput): class DatePickerWidget(forms.DateInput):
@@ -71,6 +68,9 @@ class UploadedFileWidget(forms.ClearableFileInput):
@property @property
def url(self): def url(self):
from pretix.base.models import OrderPosition
from pretix.multidomain.urlreverse import eventreverse
if isinstance(self.position, OrderPosition): if isinstance(self.position, OrderPosition):
return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={ return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
'order': self.position.order.code, 'order': self.position.order.code,

View File

@@ -3,7 +3,7 @@ from contextlib import contextmanager
from django.conf import settings from django.conf import settings
from django.utils import translation from django.utils import translation
from django.utils.formats import date_format, number_format from django.utils.formats import date_format, number_format
from django.utils.translation import ugettext from django.utils.translation import gettext
from i18nfield.fields import ( # noqa from i18nfield.fields import ( # noqa
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput, I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
) )
@@ -69,6 +69,6 @@ class LazyLocaleException(Exception):
def __str__(self): def __str__(self):
if self.msgargs: if self.msgargs:
return ugettext(self.msg) % self.msgargs return gettext(self.msg) % self.msgargs
else: else:
return ugettext(self.msg) return gettext(self.msg)

View File

@@ -10,7 +10,7 @@ from django.contrib.staticfiles import finders
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format, localize from django.utils.formats import date_format, localize
from django.utils.translation import ( from django.utils.translation import (
get_language, pgettext, ugettext, ugettext_lazy, get_language, gettext, gettext_lazy, pgettext,
) )
from PIL.Image import BICUBIC from PIL.Image import BICUBIC
from reportlab.lib import pagesizes from reportlab.lib import pagesizes
@@ -28,7 +28,7 @@ from reportlab.platypus import (
) )
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Invoice from pretix.base.models import Event, Invoice, Order
from pretix.base.signals import register_invoice_renderers from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
@@ -264,7 +264,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas): def _draw_invoice_to(self, canvas):
p = Paragraph(self.invoice.address_invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal']) p = Paragraph(bleach.clean(self.invoice.address_invoice_to, tags=[]).strip().replace('\n', '<br />\n'),
style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height) p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height) p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top) p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
@@ -422,7 +423,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.saveState() canvas.saveState()
canvas.setFont('OpenSansBd', 30) canvas.setFont('OpenSansBd', 30)
canvas.setFillColorRGB(32, 0, 0) canvas.setFillColorRGB(32, 0, 0)
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE')) canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, gettext('TEST MODE'))
canvas.restoreState() canvas.restoreState()
def _on_first_page(self, canvas: Canvas, doc): def _on_first_page(self, canvas: Canvas, doc):
@@ -459,6 +460,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _get_intro(self): def _get_intro(self):
story = [] story = []
if self.invoice.custom_field:
story.append(Paragraph(
'{}: {}'.format(self.invoice.event.settings.invoice_address_custom_field, self.invoice.custom_field),
self.stylesheet['Normal']
))
if self.invoice.internal_reference: if self.invoice.internal_reference:
story.append(Paragraph( story.append(Paragraph(
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference), pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
@@ -559,6 +566,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
]) ])
colwidths = [a * doc.width for a in (.65, .05, .30)] colwidths = [a * doc.width for a in (.65, .05, .30)]
if self.invoice.event.settings.invoice_show_payments and not self.invoice.is_cancellation and \
self.invoice.order.status == Order.STATUS_PENDING:
pending_sum = self.invoice.order.pending_sum
if pending_sum != total:
tdata.append([pgettext('invoice', 'Received payments')] + (['', '', ''] if has_taxes else ['']) + [
money_filter(pending_sum - total, self.invoice.event.currency)
])
tdata.append([pgettext('invoice', 'Outstanding payments')] + (['', '', ''] if has_taxes else ['']) + [
money_filter(pending_sum, self.invoice.event.currency)
])
tstyledata += [
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
]
table = Table(tdata, colWidths=colwidths, repeatRows=1) table = Table(tdata, colWidths=colwidths, repeatRows=1)
table.setStyle(TableStyle(tstyledata)) table.setStyle(TableStyle(tstyledata))
story.append(table) story.append(table)
@@ -667,7 +688,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
class Modern1Renderer(ClassicInvoiceRenderer): class Modern1Renderer(ClassicInvoiceRenderer):
identifier = 'modern1' identifier = 'modern1'
verbose_name = ugettext_lazy('Modern Invoice Renderer (pretix 2.7)') verbose_name = gettext_lazy('Modern Invoice Renderer (pretix 2.7)')
bottom_margin = 16.9 * mm bottom_margin = 16.9 * mm
top_margin = 16.9 * mm top_margin = 16.9 * mm
right_margin = 20 * mm right_margin = 20 * mm

View File

@@ -13,11 +13,17 @@ class Command(BaseCommand):
return parser return parser
def handle(self, *args, **options): def handle(self, *args, **options):
try:
from django_extensions.management.commands import shell_plus # noqa
cmd = 'shell_plus'
except ImportError:
cmd = 'shell'
parser = self.create_parser(sys.argv[0], sys.argv[1]) parser = self.create_parser(sys.argv[0], sys.argv[1])
flags = parser.parse_known_args(sys.argv[2:])[1] flags = parser.parse_known_args(sys.argv[2:])[1]
if "--override" in flags: if "--override" in flags:
with scopes_disabled(): with scopes_disabled():
return call_command("shell_plus", *args, **options) return call_command(cmd, *args, **options)
lookups = {} lookups = {}
for flag in flags: for flag in flags:
@@ -36,4 +42,4 @@ class Command(BaseCommand):
for app_name, app_value in lookups.items() for app_name, app_value in lookups.items()
} }
with scope(**scope_options): with scope(**scope_options):
return call_command("shell_plus", *args, **options) return call_command(cmd, *args, **options)

View File

@@ -15,7 +15,9 @@ from django.utils.translation.trans_real import (
) )
from pretix.base.settings import GlobalSettingsObject from pretix.base.settings import GlobalSettingsObject
from pretix.multidomain.urlreverse import get_domain from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain,
)
_supported = None _supported = None
@@ -231,7 +233,10 @@ class SecurityMiddleware(MiddlewareMixin):
dynamicdomain += " " + settings.SITE_URL dynamicdomain += " " + settings.SITE_URL
if hasattr(request, 'organizer') and request.organizer: if hasattr(request, 'organizer') and request.organizer:
domain = get_domain(request.organizer) if hasattr(request, 'event') and request.event:
domain = get_event_domain(request.event, fallback=True)
else:
domain = get_organizer_domain(request.organizer)
if domain: if domain:
siteurlsplit = urlsplit(settings.SITE_URL) siteurlsplit = urlsplit(settings.SITE_URL)
if siteurlsplit.port and siteurlsplit.port not in (80, 443): if siteurlsplit.port and siteurlsplit.port not in (80, 443):

View File

@@ -6,7 +6,7 @@ import django.core.validators
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
import pretix.base.validators import pretix.base.validators
from pretix.base.i18n import language from pretix.base.i18n import language

View File

@@ -7,7 +7,7 @@ from django.db import migrations, models
from django.db.models import F from django.db.models import F
from django.db.models.functions import Concat from django.db.models.functions import Concat
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
import pretix.base.models.auth import pretix.base.models.auth
import pretix.base.validators import pretix.base.validators

View File

@@ -0,0 +1,28 @@
# Generated by Django 2.2.4 on 2020-02-17 12:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0142_auto_20191215_1522'),
]
operations = [
migrations.AddField(
model_name='seat',
name='row_label',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='seat',
name='seat_label',
field=models.CharField(max_length=190, null=True),
),
migrations.AlterField(
model_name='giftcard',
name='secret',
field=models.CharField(db_index=True, max_length=190),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 2.2.9 on 2020-02-18 08:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0143_auto_20200217_1211'),
]
operations = [
migrations.AddField(
model_name='invoiceaddress',
name='custom_field',
field=models.CharField(max_length=255, null=True, blank=True),
),
migrations.AddField(
model_name='invoice',
name='custom_field',
field=models.CharField(max_length=255, null=True),
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 2.2.8 on 2020-02-10 10:38
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0144_invoiceaddress_custom_field'),
]
operations = [
migrations.CreateModel(
name='ItemMetaProperty',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(db_index=True, max_length=50)),
('default', models.TextField()),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_meta_properties', to='pretixbase.Event')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='ItemMetaValue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('value', models.TextField()),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_values', to='pretixbase.Item')),
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_values', to='pretixbase.ItemMetaProperty')),
],
options={
'unique_together': {('item', 'property')},
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.4 on 2020-03-02 11:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0145_auto_20200210_1038'),
]
operations = [
migrations.AddField(
model_name='giftcardtransaction',
name='text',
field=models.TextField(null=True),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 2.2.9 on 2020-03-21 15:12
from django.db import migrations, models
import pretix.base.models.auth
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0146_giftcardtransaction_text'),
]
operations = [
migrations.AddField(
model_name='user',
name='session_token',
field=models.CharField(default=pretix.base.models.auth.generate_session_token, max_length=32),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.0.4 on 2020-03-25 10:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0147_user_session_token'),
]
operations = [
migrations.CreateModel(
name='CancellationRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('cancellation_fee', models.DecimalField(decimal_places=2, max_digits=10)),
('refund_as_giftcard', models.BooleanField(default=False)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cancellation_requests', to='pretixbase.Order')),
],
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 3.0.4 on 2020-03-25 14:40
from django.db import migrations, models
from django.db.models import Count, OuterRef, Q, Subquery
from django.utils.timezone import now
def fill_cancellation_date(apps, schema_editor):
Order = apps.get_model('pretixbase', 'Order')
LogEntry = apps.get_model('pretixbase', 'LogEntry')
OrderPosition = apps.get_model('pretixbase', 'OrderPosition')
s = OrderPosition.all.filter(
order=OuterRef('pk'),
canceled=False,
).order_by().values('order').annotate(k=Count('id')).values('k')
for o in Order.objects.annotate(
pcnt=Subquery(s)
).filter(
Q(pcnt=0) | Q(pcnt__isnull=True) | Q(status="c")
).values('id').iterator():
le = LogEntry.objects.filter(
content_type__model="order",
object_id=o['id'],
action_type='pretix.event.order.canceled'
).order_by('-datetime').only('datetime').first()
if le:
Order.objects.filter(pk=o['id']).update(
cancellation_date=le.datetime,
last_modified=now()
)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0148_cancellationrequest'),
]
operations = [
migrations.AddField(
model_name='order',
name='cancellation_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.RunPython(fill_cancellation_date, migrations.RunPython.noop)
]

View File

@@ -0,0 +1,74 @@
# Generated by Django 3.0.4 on 2020-04-01 11:24
import django_countries.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0149_order_cancellation_date'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='city',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='cartposition',
name='company',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='cartposition',
name='country',
field=django_countries.fields.CountryField(max_length=2, null=True),
),
migrations.AddField(
model_name='cartposition',
name='state',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='cartposition',
name='street',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='cartposition',
name='zipcode',
field=models.CharField(max_length=30, null=True),
),
migrations.AddField(
model_name='orderposition',
name='city',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='orderposition',
name='company',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='orderposition',
name='country',
field=django_countries.fields.CountryField(max_length=2, null=True),
),
migrations.AddField(
model_name='orderposition',
name='state',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='orderposition',
name='street',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='orderposition',
name='zipcode',
field=models.CharField(max_length=30, null=True),
),
]

View File

@@ -10,9 +10,9 @@ from .event import (
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import ( from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
QuestionOption, Quota, SubEventItem, SubEventItemVariation, ItemVariation, Question, QuestionOption, Quota, SubEventItem,
itempicture_upload_to, SubEventItemVariation, itempicture_upload_to,
) )
from .log import LogEntry from .log import LogEntry
from .notifications import NotificationSetting from .notifications import NotificationSetting

View File

@@ -12,9 +12,9 @@ from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string, salted_hmac
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_otp.models import Device from django_otp.models import Device
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from u2flib_server.utils import ( from u2flib_server.utils import (
@@ -54,6 +54,10 @@ def generate_notifications_token():
return get_random_string(length=32) return get_random_string(length=32)
def generate_session_token():
return get_random_string(length=32)
class SuperuserPermissionSet: class SuperuserPermissionSet:
def __contains__(self, item): def __contains__(self, item):
return True return True
@@ -110,6 +114,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
) )
notifications_token = models.CharField(max_length=255, default=generate_notifications_token) notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
auth_backend = models.CharField(max_length=255, default='native') auth_backend = models.CharField(max_length=255, default='native')
session_token = models.CharField(max_length=32, default=generate_session_token)
objects = UserManager() objects = UserManager()
@@ -382,6 +387,20 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
self._staff_session_cache[session_key] = sess self._staff_session_cache[session_key] = sess
return self._staff_session_cache[session_key] return self._staff_session_cache[session_key]
def get_session_auth_hash(self):
"""
Return an HMAC that needs to
"""
key_salt = "pretix.base.models.User.get_session_auth_hash"
payload = self.password
payload += self.email
payload += self.session_token
return salted_hmac(key_salt, payload).hexdigest()
def update_session_token(self):
self.session_token = generate_session_token()
self.save(update_fields=['session_token'])
class StaffSession(models.Model): class StaffSession(models.Model):
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)

View File

@@ -1,7 +1,7 @@
from django.db import models from django.db import models
from django.db.models import Exists, OuterRef from django.db.models import Exists, OuterRef
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager from django_scopes import ScopedManager
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel

View File

@@ -3,7 +3,7 @@ import string
from django.db import models from django.db import models
from django.db.models import Max from django.db.models import Max
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
@@ -94,6 +94,7 @@ class Device(LoggedModel):
return { return {
'can_view_orders', 'can_view_orders',
'can_change_orders', 'can_change_orders',
'can_manage_gift_cards'
} }
def get_event_permission_set(self, organizer, event) -> set: def get_event_permission_set(self, organizer, event) -> set:

View File

@@ -15,9 +15,10 @@ from django.db import models
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.template.defaultfilters import date as _date from django.template.defaultfilters import date as _date
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
@@ -293,7 +294,7 @@ class Event(EventMixin, LoggedModel):
"This will be used in URLs, order codes, invoice numbers, and bank transfer references."), "This will be used in URLs, order codes, invoice numbers, and bank transfer references."),
validators=[ validators=[
RegexValidator( RegexValidator(
regex="^[a-zA-Z0-9.-]+$", regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*$",
message=_("The slug may only contain letters, numbers, dots and dashes."), message=_("The slug may only contain letters, numbers, dots and dashes."),
), ),
EventSlugBanlistValidator() EventSlugBanlistValidator()
@@ -370,6 +371,8 @@ class Event(EventMixin, LoggedModel):
""" """
self.settings.invoice_renderer = 'modern1' self.settings.invoice_renderer = 'modern1'
self.settings.invoice_include_expire_date = True self.settings.invoice_include_expire_date = True
self.settings.ticketoutput_pdf__enabled = True
self.settings.ticketoutput_passbook__enabled = True
@property @property
def social_image(self): def social_image(self):
@@ -385,7 +388,7 @@ class Event(EventMixin, LoggedModel):
if img: if img:
return urljoin(build_absolute_uri(self, 'presale:event.index'), img) return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
def free_seats(self, ignore_voucher=None, sales_channel='web'): def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
from .orders import CartPosition, Order, OrderPosition from .orders import CartPosition, Order, OrderPosition
from .vouchers import Voucher from .vouchers import Voucher
vqs = Voucher.objects.filter( vqs = Voucher.objects.filter(
@@ -416,7 +419,7 @@ class Event(EventMixin, LoggedModel):
vqs vqs
) )
).filter(has_order=False, has_cart=False, has_voucher=False) ).filter(has_order=False, has_cart=False, has_voucher=False)
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel: if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
qs = qs.filter(blocked=False) qs = qs.filter(blocked=False)
return qs return qs
@@ -515,7 +518,7 @@ class Event(EventMixin, LoggedModel):
), tz) ), tz)
def copy_data_from(self, other): def copy_data_from(self, other):
from . import ItemAddOn, ItemCategory, Item, Question, Quota from . import ItemAddOn, ItemCategory, Item, Question, Quota, ItemMetaValue
from ..signals import event_copy_data from ..signals import event_copy_data
self.plugins = other.plugins self.plugins = other.plugins
@@ -540,6 +543,14 @@ class Event(EventMixin, LoggedModel):
c.save() c.save()
c.log_action('pretix.object.cloned') c.log_action('pretix.object.cloned')
item_meta_properties_map = {}
for imp in other.item_meta_properties.all():
item_meta_properties_map[imp.pk] = imp
imp.pk = None
imp.event = self
imp.save()
imp.log_action('pretix.object.cloned')
item_map = {} item_map = {}
variation_map = {} variation_map = {}
for i in Item.objects.filter(event=other).prefetch_related('variations'): for i in Item.objects.filter(event=other).prefetch_related('variations'):
@@ -561,6 +572,12 @@ class Event(EventMixin, LoggedModel):
v.item = i v.item = i
v.save() v.save()
for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'):
imv.pk = None
imv.property = item_meta_properties_map[imv.property.pk]
imv.item = item_map[imv.item.pk]
imv.save()
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'): for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
ia.pk = None ia.pk = None
ia.base_item = item_map[ia.base_item.pk] ia.base_item = item_map[ia.base_item.pk]
@@ -608,8 +625,10 @@ class Event(EventMixin, LoggedModel):
q.dependency_question = question_map[q.dependency_question_id] q.dependency_question = question_map[q.dependency_question_id]
q.save(update_fields=['dependency_question']) q.save(update_fields=['dependency_question'])
checkin_list_map = {}
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'): for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'):
items = list(cl.limit_products.all()) items = list(cl.limit_products.all())
checkin_list_map[cl.pk] = cl
cl.pk = None cl.pk = None
cl.event = self cl.event = self
cl.save() cl.save()
@@ -633,6 +652,8 @@ class Event(EventMixin, LoggedModel):
for s in other.seats.filter(subevent__isnull=True): for s in other.seats.filter(subevent__isnull=True):
s.pk = None s.pk = None
s.event = self s.event = self
if s.product_id:
s.product = item_map[s.product_id]
s.save() s.save()
for s in other.settings._objects.all(): for s in other.settings._objects.all():
@@ -662,7 +683,7 @@ class Event(EventMixin, LoggedModel):
event_copy_data.send( event_copy_data.send(
sender=self, other=other, sender=self, other=other,
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map, tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
question_map=question_map question_map=question_map, checkin_list_map=checkin_list_map
) )
def get_payment_providers(self, cached=False) -> dict: def get_payment_providers(self, cached=False) -> dict:
@@ -1014,9 +1035,13 @@ class SubEvent(EventMixin, LoggedModel):
ordering = ("date_from", "name") ordering = ("date_from", "name")
def __str__(self): def __str__(self):
return '{} - {}'.format(self.name, self.get_date_range_display()) return '{} - {} {}'.format(
self.name,
self.get_date_range_display(),
date_format(self.date_from.astimezone(self.timezone), "TIME_FORMAT") if self.settings.show_times else ""
).strip()
def free_seats(self, ignore_voucher=None, sales_channel='web'): def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
from .orders import CartPosition, Order, OrderPosition from .orders import CartPosition, Order, OrderPosition
from .vouchers import Voucher from .vouchers import Voucher
vqs = Voucher.objects.filter( vqs = Voucher.objects.filter(
@@ -1050,7 +1075,7 @@ class SubEvent(EventMixin, LoggedModel):
vqs vqs
) )
).filter(has_order=False, has_cart=False, has_voucher=False) ).filter(has_order=False, has_cart=False, has_voucher=False)
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel: if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
qs = qs.filter(blocked=False) qs = qs.filter(blocked=False)
return qs return qs

View File

@@ -1,16 +1,17 @@
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.banlist import banned from pretix.base.banlist import banned
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
def gen_giftcard_secret(length): def gen_giftcard_secret(length=8):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True: while True:
code = get_random_string(length=length, allowed_chars=charset) code = get_random_string(length=length, allowed_chars=charset)
@@ -50,6 +51,12 @@ class GiftCard(LoggedModel):
max_length=190, max_length=190,
db_index=True, db_index=True,
verbose_name=_('Gift card code'), verbose_name=_('Gift card code'),
validators=[
RegexValidator(
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]+$",
message=_("The giftcard code may only contain letters, numbers, dots and dashes."),
)
],
) )
testmode = models.BooleanField( testmode = models.BooleanField(
verbose_name=_('Test mode card'), verbose_name=_('Test mode card'),
@@ -76,6 +83,7 @@ class GiftCard(LoggedModel):
class Meta: class Meta:
unique_together = (('secret', 'issuer'),) unique_together = (('secret', 'issuer'),)
ordering = ("issuance",)
class GiftCardTransaction(models.Model): class GiftCardTransaction(models.Model):
@@ -112,6 +120,7 @@ class GiftCardTransaction(models.Model):
blank=True, blank=True,
on_delete=models.PROTECT on_delete=models.PROTECT
) )
text = models.TextField(blank=True, null=True)
class Meta: class Meta:
ordering = ("datetime",) ordering = ("datetime",)

View File

@@ -111,6 +111,7 @@ class Invoice(models.Model):
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255) file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
internal_reference = models.TextField(blank=True) internal_reference = models.TextField(blank=True)
custom_field = models.CharField(max_length=255, null=True)
objects = ScopedManager(organizer='event__organizer') objects = ScopedManager(organizer='event__organizer')
@@ -120,13 +121,19 @@ class Invoice(models.Model):
@property @property
def full_invoice_from(self): def full_invoice_from(self):
taxidrow = ""
if self.invoice_from_tax_id:
if str(self.invoice_from_country) == "AU":
taxidrow = "ABN: %s" % self.invoice_from_tax_id
else:
taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id
parts = [ parts = [
self.invoice_from_name, self.invoice_from_name,
self.invoice_from, self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""), (self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
self.invoice_from_country.name if self.invoice_from_country else "", self.invoice_from_country.name if self.invoice_from_country else "",
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "", 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 "", taxidrow,
] ]
return '\n'.join([p.strip() for p in parts if p and p.strip()]) return '\n'.join([p.strip() for p in parts if p and p.strip()])
@@ -150,9 +157,12 @@ class Invoice(models.Model):
state_name = self.invoice_to_state state_name = self.invoice_to_state
if str(self.invoice_to_country) in COUNTRIES_WITH_STATE_IN_ADDRESS: if str(self.invoice_to_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_to_country)][1] == 'long': if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_to_country)][1] == 'long':
state_name = pycountry.subdivisions.get( try:
code='{}-{}'.format(self.invoice_to_country, self.invoice_to_state) state_name = pycountry.subdivisions.get(
).name code='{}-{}'.format(self.invoice_to_country, self.invoice_to_state)
).name
except:
pass
parts = [ parts = [
self.invoice_to_company, self.invoice_to_company,

View File

@@ -1,6 +1,6 @@
import sys import sys
import uuid import uuid
from collections import Counter from collections import Counter, OrderedDict
from datetime import date, datetime, time from datetime import date, datetime, time
from decimal import Decimal, DecimalException from decimal import Decimal, DecimalException
from typing import Tuple from typing import Tuple
@@ -9,13 +9,14 @@ import dateutil.parser
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import F, Func, Q, Sum from django.db.models import F, Func, Q, Sum
from django.utils import formats from django.utils import formats
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import ScopedManager from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
@@ -454,7 +455,8 @@ class Item(LoggedModel):
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='') rate=Decimal('0.00'), name='')
else: else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, currency=currency) t = self.tax_rule.tax(price, base_price_is=base_price_is,
currency=currency or self.event.currency)
if include_bundled: if include_bundled:
for b in self.bundles.all(): for b in self.bundles.all():
@@ -591,6 +593,16 @@ class Item(LoggedModel):
if from_date > until_date: if from_date > until_date:
raise ValidationError(_('The item\'s availability cannot end before it starts.')) raise ValidationError(_('The item\'s availability cannot end before it starts.'))
@property
def meta_data(self):
data = {p.name: p.default for p in self.event.item_meta_properties.all()}
if hasattr(self, 'meta_values_cached'):
data.update({v.property.name: v.value for v in self.meta_values_cached})
else:
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
class ItemVariation(models.Model): class ItemVariation(models.Model):
""" """
@@ -1105,10 +1117,13 @@ class Question(LoggedModel):
return None return None
if self.type == Question.TYPE_CHOICE: if self.type == Question.TYPE_CHOICE:
try: q = Q(identifier=answer)
return self.options.get(Q(pk=answer) | Q(identifier=answer)) if isinstance(answer, int) or answer.isdigit():
except: q |= Q(pk=answer)
o = self.options.filter(q).first()
if not o:
raise ValidationError(_('Invalid option selected.')) raise ValidationError(_('Invalid option selected.'))
return o
elif self.type == Question.TYPE_CHOICE_MULTIPLE: elif self.type == Question.TYPE_CHOICE_MULTIPLE:
if isinstance(answer, str): if isinstance(answer, str):
l_ = list(self.options.filter( l_ = list(self.options.filter(
@@ -1541,3 +1556,57 @@ class Quota(LoggedModel):
else: else:
if subevent: if subevent:
raise ValidationError(_('The subevent does not belong to this event.')) raise ValidationError(_('The subevent does not belong to this event.'))
class ItemMetaProperty(LoggedModel):
"""
An event can have ItemMetaProperty objects attached to define meta information fields
for its items. This information can be re-used for example in ticket layouts.
:param event: The event this property is defined for.
:type event: Event
:param name: Name
:type name: Name of the property, used in various places
:param default: Default value
:type default: str
"""
event = models.ForeignKey(Event, related_name="item_meta_properties", on_delete=models.CASCADE)
name = models.CharField(
max_length=50, db_index=True,
help_text=_(
"Can not contain spaces or special characters except underscores"
),
validators=[
RegexValidator(
regex="^[a-zA-Z0-9_]+$",
message=_("The property name may only contain letters, numbers and underscores."),
),
],
verbose_name=_("Name"),
)
default = models.TextField(blank=True)
class ItemMetaValue(LoggedModel):
"""
A meta-data value assigned to an item.
:param item: The item this metadata is valid for
:type item: Item
:param property: The property this value belongs to
:type property: ItemMetaProperty
:param value: The actual value
:type value: str
"""
item = models.ForeignKey('Item', on_delete=models.CASCADE, related_name='meta_values')
property = models.ForeignKey('ItemMetaProperty', on_delete=models.CASCADE, related_name='item_values')
value = models.TextField()
class Meta:
unique_together = ('item', 'property')
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)

View File

@@ -6,7 +6,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape from django.utils.html import escape
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.signals import logentry_object_link from pretix.base.signals import logentry_object_link

View File

@@ -1,5 +1,5 @@
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
class NotificationSetting(models.Model): class NotificationSetting(models.Model):

View File

@@ -4,6 +4,7 @@ import json
import logging import logging
import os import os
import string import string
from collections import Counter
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
@@ -25,7 +26,7 @@ from django.utils.encoding import escape_uri_path
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country, CountryField from django_countries.fields import Country, CountryField
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
@@ -43,6 +44,7 @@ from pretix.base.services.locking import NoLockManager
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import order_gracefully_delete from pretix.base.signals import order_gracefully_delete
from ...helpers.countries import CachedCountries
from .base import LockModel, LoggedModel from .base import LockModel, LoggedModel
from .event import Event, SubEvent from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota from .items import Item, ItemVariation, Question, QuestionOption, Quota
@@ -151,6 +153,9 @@ class Order(LockModel, LoggedModel):
datetime = models.DateTimeField( datetime = models.DateTimeField(
verbose_name=_("Date"), db_index=True verbose_name=_("Date"), db_index=True
) )
cancellation_date = models.DateTimeField(
null=True, blank=True
)
expires = models.DateTimeField( expires = models.DateTimeField(
verbose_name=_("Expiration date") verbose_name=_("Expiration date")
) )
@@ -400,10 +405,13 @@ class Order(LockModel, LoggedModel):
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last: if term_last:
if self.event.has_subevents and subevents: if self.event.has_subevents and subevents:
term_last = min([ terms = [
term_last.datetime(se).date() term_last.datetime(se).date()
for se in subevents for se in subevents
]) ]
if not terms:
return
term_last = min(terms)
else: else:
term_last = term_last.datetime(self.event).date() term_last = term_last.datetime(self.event).date()
term_last = make_aware(datetime.combine( term_last = make_aware(datetime.combine(
@@ -423,7 +431,7 @@ class Order(LockModel, LoggedModel):
def cancel_allowed(self): def cancel_allowed(self):
return ( return (
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions self.status in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED) and self.count_positions
) )
@cached_property @cached_property
@@ -434,26 +442,28 @@ class Order(LockModel, LoggedModel):
until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper) until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
if until: if until:
if self.event.has_subevents: if self.event.has_subevents:
return min([ terms = [
until.datetime(se) until.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
]) ]
return min(terms) if terms else None
else: else:
return until.datetime(self.event) return until.datetime(self.event)
@cached_property @cached_property
def user_cancel_fee(self): def user_cancel_fee(self):
fee = Decimal('0.00') fee = Decimal('0.00')
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
if self.event.settings.cancel_allow_user_paid_keep_fees: if self.event.settings.cancel_allow_user_paid_keep_fees:
fee += self.fees.filter( fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE) fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate( ).aggregate(
s=Sum('value') s=Sum('value')
)['s'] or 0 )['s'] or 0
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
return round_decimal(fee, self.event.currency) return round_decimal(fee, self.event.currency)
@property @property
@@ -464,6 +474,8 @@ class Order(LockModel, LoggedModel):
""" """
from .checkin import Checkin from .checkin import Checkin
if self.cancellation_requests.exists():
return False
positions = list( positions = list(
self.positions.all().annotate( self.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
@@ -586,10 +598,11 @@ class Order(LockModel, LoggedModel):
modify_deadline = self.event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper) modify_deadline = self.event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
if self.event.has_subevents and modify_deadline: if self.event.has_subevents and modify_deadline:
modify_deadline = min([ dates = [
modify_deadline.datetime(se) modify_deadline.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
]) ]
modify_deadline = min(dates) if dates else None
elif modify_deadline: elif modify_deadline:
modify_deadline = modify_deadline.datetime(self.event) modify_deadline = modify_deadline.datetime(self.event)
@@ -620,10 +633,11 @@ class Order(LockModel, LoggedModel):
dl_date = self.event.settings.get('ticket_download_date', as_type=RelativeDateWrapper) dl_date = self.event.settings.get('ticket_download_date', as_type=RelativeDateWrapper)
if dl_date: if dl_date:
if self.event.has_subevents: if self.event.has_subevents:
dl_date = min([ dates = [
dl_date.datetime(se) dl_date.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
]) ]
dl_date = min(dates) if dates else None
else: else:
dl_date = dl_date.datetime(self.event) dl_date = dl_date.datetime(self.event)
return dl_date return dl_date
@@ -648,10 +662,14 @@ class Order(LockModel, LoggedModel):
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last: if term_last:
if self.event.has_subevents: if self.event.has_subevents:
term_last = min([ terms = [
term_last.datetime(se).date() term_last.datetime(se).date()
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
]) ]
if terms:
term_last = min(terms)
else:
term_last = None
else: else:
term_last = term_last.datetime(self.event).date() term_last = term_last.datetime(self.event).date()
term_last = make_aware(datetime.combine( term_last = make_aware(datetime.combine(
@@ -683,16 +701,19 @@ class Order(LockModel, LoggedModel):
return self._is_still_available(count_waitinglist=count_waitinglist, force=force) return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]: def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False,
check_voucher_usage=False) -> Union[bool, str]:
error_messages = { error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'), 'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'), 'seat_unavailable': _('The seat "{seat}" is no longer available.'),
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'), 'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
} }
now_dt = now_dt or now() now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher') positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher')
quota_cache = {} quota_cache = {}
v_budget = {} v_budget = {}
v_usage = Counter()
try: try:
for i, op in enumerate(positions): for i, op in enumerate(positions):
if op.seat: if op.seat:
@@ -711,6 +732,13 @@ class Order(LockModel, LoggedModel):
)) ))
v_budget[op.voucher] -= disc v_budget[op.voucher] -= disc
if op.voucher and check_voucher_usage:
v_usage[op.voucher.pk] += 1
if v_usage[op.voucher.pk] + op.voucher.redeemed > op.voucher.max_usages:
raise Quota.QuotaExceededException(error_messages['voucher_usages'].format(
voucher=op.voucher.code
))
quotas = list(op.quotas) quotas = list(op.quotas)
if len(quotas) == 0: if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format( raise Quota.QuotaExceededException(error_messages['unavailable'].format(
@@ -900,7 +928,7 @@ class QuestionAnswer(models.Model):
@property @property
def is_image(self): def is_image(self):
return any(self.file.name.endswith(e) for e in ('.jpg', '.png', '.gif', '.tiff', '.bmp', '.jpeg')) return any(self.file.name.lower().endswith(e) for e in ('.jpg', '.png', '.gif', '.tiff', '.bmp', '.jpeg'))
@property @property
def file_name(self): def file_name(self):
@@ -1038,6 +1066,13 @@ class AbstractPosition(models.Model):
'Seat', null=True, blank=True, on_delete=models.PROTECT 'Seat', null=True, blank=True, on_delete=models.PROTECT
) )
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
country = CountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
class Meta: class Meta:
abstract = True abstract = True
@@ -1258,6 +1293,36 @@ class OrderPayment(models.Model):
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth) self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order) order_paid.send(self.order.event, order=self.order)
def fail(self, info=None, user=None, auth=None):
"""
Marks the order as failed and sets info to ``info``, but only if the order is in ``created`` or ``pending``
state. This is equivalent to setting ``state`` to ``OrderPayment.PAYMENT_STATE_FAILED`` and logging a failure,
but it adds strong database logging since we do not want to report a failure for an order that has just
been marked as paid.
"""
with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
if locked_instance.state not in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
# Race condition detected, this payment is already confirmed
logger.info('Failed payment {} but ignored due to likely race condition.'.format(
self.full_id,
))
return
if isinstance(info, str):
locked_instance.info = info
elif info:
locked_instance.info_data = info
locked_instance.state = OrderPayment.PAYMENT_STATE_FAILED
locked_instance.save(update_fields=['state', 'info'])
self.refresh_from_db()
self.order.log_action('pretix.event.order.payment.failed', {
'local_id': self.local_id,
'provider': self.provider,
'info': info,
}, user=user, auth=auth)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='', def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_date=None): ignore_date=False, lock=True, payment_date=None):
""" """
@@ -1285,6 +1350,9 @@ class OrderPayment(models.Model):
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk) locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED: if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
# Race condition detected, this payment is already confirmed # Race condition detected, this payment is already confirmed
logger.info('Confirmed payment {} but ignored due to likely race condition.'.format(
self.full_id,
))
return return
locked_instance.state = self.PAYMENT_STATE_CONFIRMED locked_instance.state = self.PAYMENT_STATE_CONFIRMED
@@ -1305,6 +1373,7 @@ class OrderPayment(models.Model):
}, user=user, auth=auth) }, user=user, auth=auth)
if self.order.status in (Order.STATUS_PAID, Order.STATUS_CANCELED): if self.order.status in (Order.STATUS_PAID, Order.STATUS_CANCELED):
logger.info('Confirmed payment {} but order is in status {}.'.format(self.full_id, self.order.status))
return return
payment_sum = self.order.payments.filter( payment_sum = self.order.payments.filter(
@@ -1315,6 +1384,9 @@ class OrderPayment(models.Model):
OrderRefund.REFUND_STATE_CREATED) OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if payment_sum - refund_sum < self.order.total: if payment_sum - refund_sum < self.order.total:
logger.info('Confirmed payment {} but payment sum is {} and refund sum is.'.format(
self.full_id, payment_sum, refund_sum
))
return return
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock: if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock:
@@ -1873,8 +1945,9 @@ class OrderPosition(AbstractPosition):
if self.tax_rate is None: if self.tax_rate is None:
self._calculate_tax() self._calculate_tax()
self.order.touch() self.order.touch()
if self.pk is None: if not self.pk:
while OrderPosition.all.filter(secret=self.secret).exists(): while OrderPosition.all.filter(secret=self.secret,
order__event__organizer_id=self.order.event.organizer_id).exists():
self.secret = generate_position_secret() self.secret = generate_position_secret()
if not self.pseudonymization_id: if not self.pseudonymization_id:
@@ -1891,9 +1964,10 @@ class OrderPosition(AbstractPosition):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True: while True:
code = get_random_string(length=10, allowed_chars=charset) code = get_random_string(length=10, allowed_chars=charset)
if not OrderPosition.all.filter(pseudonymization_id=code).exists(): with scopes_disabled():
self.pseudonymization_id = code if not OrderPosition.all.filter(pseudonymization_id=code).exists():
return self.pseudonymization_id = code
return
@property @property
def event(self): def event(self):
@@ -2040,11 +2114,13 @@ class InvoiceAddress(models.Model):
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), 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) city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False) country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country')) country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
countries=CachedCountries)
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True) state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True)
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'), vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
help_text=_('Only for business customers within the EU.')) help_text=_('Only for business customers within the EU.'))
vat_id_validated = models.BooleanField(default=False) vat_id_validated = models.BooleanField(default=False)
custom_field = models.CharField(max_length=255, null=True, blank=True)
internal_reference = models.TextField( internal_reference = models.TextField(
verbose_name=_('Internal reference'), verbose_name=_('Internal reference'),
help_text=_('This reference will be printed on your invoice for your convenience.'), help_text=_('This reference will be printed on your invoice for your convenience.'),
@@ -2146,6 +2222,13 @@ class CachedCombinedTicket(models.Model):
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
class CancellationRequest(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='cancellation_requests')
created = models.DateTimeField(auto_now_add=True)
cancellation_fee = models.DecimalField(max_digits=10, decimal_places=2)
refund_as_giftcard = models.BooleanField(default=False)
@receiver(post_delete, sender=CachedTicket) @receiver(post_delete, sender=CachedTicket)
def cachedticket_delete(sender, instance, **kwargs): def cachedticket_delete(sender, instance, **kwargs):
if instance.file: if instance.file:

View File

@@ -5,7 +5,7 @@ from django.db import models
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.validators import OrganizerSlugBanlistValidator from pretix.base.validators import OrganizerSlugBanlistValidator
@@ -37,7 +37,7 @@ class Organizer(LoggedModel):
"once. This is being used in URLs to refer to your organizer accounts and your events."), "once. This is being used in URLs to refer to your organizer accounts and your events."),
validators=[ validators=[
RegexValidator( RegexValidator(
regex="^[a-zA-Z0-9.-]+$", regex="^[a-zA-Z0-9][a-zA-Z0-9.-]+$",
message=_("The slug may only contain letters, numbers, dots and dashes.") message=_("The slug may only contain letters, numbers, dots and dashes.")
), ),
OrganizerSlugBanlistValidator() OrganizerSlugBanlistValidator()

View File

@@ -8,7 +8,7 @@ from django.db import models
from django.db.models import F, Q from django.db.models import F, Q
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext, ugettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
@@ -28,7 +28,8 @@ class SeatingPlanLayoutValidator:
try: try:
jsonschema.validate(val, schema) jsonschema.validate(val, schema)
except jsonschema.ValidationError as e: except jsonschema.ValidationError as e:
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(str(e))) e = str(e).replace('%', '%%')
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(e))
class SeatingPlan(LoggedModel): class SeatingPlan(LoggedModel):
@@ -40,7 +41,7 @@ class SeatingPlan(LoggedModel):
layout = models.TextField(validators=[SeatingPlanLayoutValidator()]) layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
Category = namedtuple('Categrory', 'name') Category = namedtuple('Categrory', 'name')
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank') RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank row_label seat_label')
def __str__(self): def __str__(self):
return self.name return self.name
@@ -69,11 +70,17 @@ class SeatingPlan(LoggedModel):
# optimization, because this way we do not need to update the rank of very seat if we change a plan a little. # optimization, because this way we do not need to update the rank of very seat if we change a plan a little.
for zi, z in enumerate(self.layout_data['zones']): for zi, z in enumerate(self.layout_data['zones']):
for ri, r in enumerate(z['rows']): for ri, r in enumerate(z['rows']):
row_label = None
if r.get('row_label'):
row_label = r['row_label'].replace("%s", r.get('row_number', str(ri)))
try: try:
row_rank = int(r['row_number']) row_rank = int(r['row_number'])
except ValueError: except ValueError:
row_rank = ri row_rank = ri
for si, s in enumerate(r['seats']): for si, s in enumerate(r['seats']):
seat_label = None
if r.get('seat_label'):
seat_label = r['seat_label'].replace("%s", s.get('seat_number', str(si)))
try: try:
seat_rank = int(s['seat_number']) seat_rank = int(s['seat_number'])
except ValueError: except ValueError:
@@ -87,6 +94,8 @@ class SeatingPlan(LoggedModel):
guid=s['seat_guid'], guid=s['seat_guid'],
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme? name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
row=r['row_number'], row=r['row_number'],
row_label=row_label,
seat_label=seat_label,
zone=z['name'], zone=z['name'],
category=s['category'], category=s['category'],
sorting_rank=rank sorting_rank=rank
@@ -114,7 +123,9 @@ class Seat(models.Model):
name = models.CharField(max_length=190) name = models.CharField(max_length=190)
zone_name = models.CharField(max_length=190, blank=True, default="") zone_name = models.CharField(max_length=190, blank=True, default="")
row_name = models.CharField(max_length=190, blank=True, default="") row_name = models.CharField(max_length=190, blank=True, default="")
row_label = models.CharField(max_length=190, null=True)
seat_number = models.CharField(max_length=190, blank=True, default="") seat_number = models.CharField(max_length=190, blank=True, default="")
seat_label = models.CharField(max_length=190, null=True)
seat_guid = models.CharField(max_length=190, db_index=True) seat_guid = models.CharField(max_length=190, db_index=True)
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE) product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
blocked = models.BooleanField(default=False) blocked = models.BooleanField(default=False)
@@ -127,10 +138,17 @@ class Seat(models.Model):
parts = [] parts = []
if self.zone_name: if self.zone_name:
parts.append(self.zone_name) parts.append(self.zone_name)
if self.row_name:
if self.row_label:
parts.append(self.row_label)
elif self.row_name:
parts.append(gettext('Row {number}').format(number=self.row_name)) parts.append(gettext('Row {number}').format(number=self.row_name))
if self.seat_number:
if self.seat_label:
parts.append(self.seat_label)
elif self.seat_number:
parts.append(gettext('Seat {number}').format(number=self.seat_number)) parts.append(gettext('Seat {number}').format(number=self.seat_number))
if not parts: if not parts:
return self.name return self.name
return ', '.join(parts) return ', '.join(parts)

View File

@@ -4,7 +4,7 @@ from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField from django_countries.fields import CountryField
from i18nfield.fields import I18nCharField from i18nfield.fields import I18nCharField

View File

@@ -8,7 +8,7 @@ from django.db.models import F, OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from pretix.base.banlist import banned from pretix.base.banlist import banned

View File

@@ -3,7 +3,7 @@ from datetime import timedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import models, transaction
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager from django_scopes import ScopedManager
from pretix.base.email import get_email_context from pretix.base.email import get_email_context

View File

@@ -3,7 +3,7 @@ from collections import OrderedDict, namedtuple
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import Event, LogEntry from pretix.base.models import Event, LogEntry
from pretix.base.signals import register_notification_types from pretix.base.signals import register_notification_types
@@ -223,6 +223,12 @@ def register_default_notification_types(sender, **kwargs):
_('Order canceled'), _('Order canceled'),
_('Order {order.code} has been canceled.') _('Order {order.code} has been canceled.')
), ),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.reactivated',
_('Order reactivated'),
_('Order {order.code} has been reactivated.')
),
ParametrizedOrderNotificationType( ParametrizedOrderNotificationType(
sender, sender,
'pretix.event.order.expired', 'pretix.event.order.expired',

View File

@@ -325,7 +325,7 @@ class InvoiceAddressState(ImportColumn):
@property @property
def verbose_name(self): def verbose_name(self):
return _('Invoice address') + ': ' + _('State') return _('Invoice address') + ': ' + pgettext('address', 'State')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:
@@ -398,6 +398,99 @@ class AttendeeEmail(ImportColumn):
position.attendee_email = value position.attendee_email = value
class AttendeeCompany(ImportColumn):
identifier = 'attendee_company'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('Company')
def assign(self, value, order, position, invoice_address, **kwargs):
position.company = value or ''
class AttendeeStreet(ImportColumn):
identifier = 'attendee_street'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('Address')
def assign(self, value, order, position, invoice_address, **kwargs):
position.address = value or ''
class AttendeeZip(ImportColumn):
identifier = 'attendee_zipcode'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('ZIP code')
def assign(self, value, order, position, invoice_address, **kwargs):
position.zipcode = value or ''
class AttendeeCity(ImportColumn):
identifier = 'attendee_city'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('City')
def assign(self, value, order, position, invoice_address, **kwargs):
position.city = value or ''
class AttendeeCountry(ImportColumn):
identifier = 'attendee_country'
default_value = None
@property
def initial(self):
return 'static:' + str(guess_country(self.event))
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('Country')
def static_choices(self):
return list(countries)
def clean(self, value, previous_values):
if value and not Country(value).numeric:
raise ValidationError(_("Please enter a valid country code."))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
position.country = value or ''
class AttendeeState(ImportColumn):
identifier = 'attendee_state'
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('State')
def clean(self, value, previous_values):
if value:
if previous_values.get('attendee_country') not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(_("States are not supported for this country."))
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[previous_values.get('attendee_country')]
match = [
s for s in pycountry.subdivisions.get(country_code=previous_values.get('attendee_country'))
if s.type in types and (s.code[3:] == value or s.name == value)
]
if len(match) == 0:
raise ValidationError(_("Please enter a valid state."))
return match[0].code[3:]
def assign(self, value, order, position, invoice_address, **kwargs):
position.state = value or ''
class Price(ImportColumn): class Price(ImportColumn):
identifier = 'price' identifier = 'price'
verbose_name = gettext_lazy('Price') verbose_name = gettext_lazy('Price')
@@ -596,6 +689,12 @@ def get_all_columns(event):
default.append(AttendeeNamePart(event, n, l)) default.append(AttendeeNamePart(event, n, l))
default += [ default += [
AttendeeEmail(event), AttendeeEmail(event),
AttendeeCompany(event),
AttendeeStreet(event),
AttendeeZip(event),
AttendeeCity(event),
AttendeeCountry(event),
AttendeeState(event),
Price(event), Price(event),
Secret(event), Secret(event),
Locale(event), Locale(event),

View File

@@ -1,3 +1,4 @@
import hashlib
import json import json
import logging import logging
from collections import OrderedDict from collections import OrderedDict
@@ -14,8 +15,9 @@ from django.dispatch import receiver
from django.forms import Form from django.forms import Form
from django.http import HttpRequest from django.http import HttpRequest
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries import Countries from django_countries import Countries
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
@@ -27,12 +29,13 @@ from pretix.base.models import (
OrderRefund, Quota, OrderRefund, Quota,
) )
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.services.cart import get_fees
from pretix.base.settings import SettingsSandbox from pretix.base.settings import SettingsSandbox
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.money import DecimalTextInput from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
from pretix.presale.views import get_cart, get_cart_total from pretix.presale.views import get_cart, get_cart_total
from pretix.presale.views.cart import cart_session, get_or_create_cart_id from pretix.presale.views.cart import cart_session, get_or_create_cart_id
@@ -204,6 +207,13 @@ class BasePaymentProvider:
implementation. implementation.
""" """
places = settings.CURRENCY_PLACES.get(self.event.currency, 2) places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
if not self.settings.get('_hidden_seed'):
self.settings.set('_hidden_seed', get_random_string(64))
hidden_url = build_absolute_uri(self.event, 'presale:event.payment.unlock', kwargs={
'hash': hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest(),
})
d = OrderedDict([ d = OrderedDict([
('_enabled', ('_enabled',
forms.BooleanField( forms.BooleanField(
@@ -297,7 +307,30 @@ class BasePaymentProvider:
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
help_text=_( help_text=_(
'Only allow the usage of this payment provider in the following sales channels'), 'Only allow the usage of this payment provider in the following sales channels'),
)) )),
('_hidden',
forms.BooleanField(
label=_('Hide payment method'),
required=False,
help_text=_(
'The payment method will not be shown by default but only to people who enter the shop through '
'a special link.'
),
)),
('_hidden_url',
forms.URLField(
label=_('Link to enable payment method'),
widget=forms.TextInput(attrs={
'readonly': 'readonly',
'data-display-dependency': '#id_%s_hidden' % self.settings.get_prefix(),
'value': hidden_url,
}),
required=False,
initial=hidden_url,
help_text=_(
'Share this link with customers who should use this payment method.'
),
)),
]) ])
d['_restricted_countries']._as_type = list d['_restricted_countries']._as_type = list
d['_restrict_to_sales_channels']._as_type = list d['_restrict_to_sales_channels']._as_type = list
@@ -378,28 +411,31 @@ class BasePaymentProvider:
availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper) availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper)
if availability_date: if availability_date:
if self.event.has_subevents and cart_id: if self.event.has_subevents and cart_id:
availability_date = min([ dates = [
availability_date.datetime(se).date() availability_date.datetime(se).date()
for se in self.event.subevents.filter( for se in self.event.subevents.filter(
id__in=CartPosition.objects.filter( id__in=CartPosition.objects.filter(
cart_id=cart_id, event=self.event cart_id=cart_id, event=self.event
).values_list('subevent', flat=True) ).values_list('subevent', flat=True)
) )
]) ]
availability_date = min(dates) if dates else None
elif self.event.has_subevents and order: elif self.event.has_subevents and order:
availability_date = min([ dates = [
availability_date.datetime(se).date() availability_date.datetime(se).date()
for se in self.event.subevents.filter( for se in self.event.subevents.filter(
id__in=order.positions.values_list('subevent', flat=True) id__in=order.positions.values_list('subevent', flat=True)
) )
]) ]
availability_date = min(dates) if dates else None
elif self.event.has_subevents: elif self.event.has_subevents:
logger.error('Payment provider is not subevent-ready.') logger.error('Payment provider is not subevent-ready.')
return False return False
else: else:
availability_date = availability_date.datetime(self.event).date() availability_date = availability_date.datetime(self.event).date()
return availability_date >= now_dt.astimezone(tz).date() if availability_date:
return availability_date >= now_dt.astimezone(tz).date()
return True return True
@@ -433,6 +469,11 @@ class BasePaymentProvider:
if self.settings._total_min is not None: if self.settings._total_min is not None:
pricing = pricing and total >= Decimal(self.settings._total_min) pricing = pricing and total >= Decimal(self.settings._total_min)
if self.settings.get('_hidden', as_type=bool):
hashes = request.session.get('pretix_unlock_hashes', [])
if hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest() not in hashes:
return False
def get_invoice_address(): def get_invoice_address():
if not hasattr(request, '_checkout_flow_invoice_address'): if not hasattr(request, '_checkout_flow_invoice_address'):
cs = cart_session(request) cs = cart_session(request)
@@ -602,6 +643,9 @@ class BasePaymentProvider:
if self.settings._total_min is not None and ps < Decimal(self.settings._total_min): if self.settings._total_min is not None and ps < Decimal(self.settings._total_min):
return False return False
if self.settings.get('_hidden', as_type=bool):
return False
restricted_countries = self.settings.get('_restricted_countries', as_type=list) restricted_countries = self.settings.get('_restricted_countries', as_type=list)
if restricted_countries: if restricted_countries:
try: try:
@@ -687,7 +731,7 @@ class BasePaymentProvider:
On failure, you should raise a PaymentException. On failure, you should raise a PaymentException.
""" """
payment.state = OrderPayment.PAYMENT_STATE_CANCELED payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.save() payment.save(update_fields=['state'])
def execute_refund(self, refund: OrderRefund): def execute_refund(self, refund: OrderRefund):
""" """
@@ -721,6 +765,16 @@ class BasePaymentProvider:
""" """
return {} return {}
def matching_id(self, payment: OrderPayment):
"""
Will be called to get an ID for a matching this payment when comparing pretix records with records of an external
source. This should return the main transaction ID for your API.
:param payment: The payment in question.
:return: A string or None
"""
return None
class PaymentException(Exception): class PaymentException(Exception):
pass pass
@@ -1053,8 +1107,16 @@ class GiftCardPayment(BasePaymentProvider):
return return
cs['gift_cards'] = cs['gift_cards'] + [gc.pk] cs['gift_cards'] = cs['gift_cards'] + [gc.pk]
remainder = cart['total'] - gc.value total = sum(p.total for p in cart['positions'])
if remainder >= Decimal('0.00'): # Recompute fees. Some plugins, e.g. pretix-servicefees, change their fee schedule if a gift card is
# applied.
fees = get_fees(
self.event, request, total, cart['invoice_address'], cs.get('payment'),
cart['raw']
)
total += sum([f.value for f in fees])
remainder = total - gc.value
if remainder > Decimal('0.00'):
del cs['payment'] del cs['payment']
messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format( messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format(
money_filter(remainder, self.event.currency) money_filter(remainder, self.event.currency)
@@ -1157,6 +1219,7 @@ class GiftCardPayment(BasePaymentProvider):
) )
refund.info_data = { refund.info_data = {
'gift_card': gc.pk, 'gift_card': gc.pk,
'gift_card_code': gc.secret,
'transaction_id': trans.pk, 'transaction_id': trans.pk,
} }
refund.done() refund.done()

View File

@@ -1,7 +1,7 @@
import copy import copy
import itertools
import logging import logging
import os import os
import re
import subprocess import subprocess
import tempfile import tempfile
import uuid import uuid
@@ -9,16 +9,15 @@ from collections import OrderedDict
from functools import partial from functools import partial
from io import BytesIO from io import BytesIO
import bleach
from arabic_reshaper import ArabicReshaper from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display from bidi.algorithm import get_display
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import conditional_escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from PyPDF2 import PdfFileReader from PyPDF2 import PdfFileReader
from pytz import timezone from pytz import timezone
from reportlab.graphics import renderPDF from reportlab.graphics import renderPDF
@@ -64,32 +63,32 @@ DEFAULT_VARIABLES = OrderedDict((
("item", { ("item", {
"label": _("Product name"), "label": _("Product name"),
"editor_sample": _("Sample product"), "editor_sample": _("Sample product"),
"evaluate": lambda orderposition, order, event: escape(str(orderposition.item.name)) "evaluate": lambda orderposition, order, event: str(orderposition.item.name)
}), }),
("variation", { ("variation", {
"label": _("Variation name"), "label": _("Variation name"),
"editor_sample": _("Sample variation"), "editor_sample": _("Sample variation"),
"evaluate": lambda op, order, event: escape(str(op.variation) if op.variation else '') "evaluate": lambda op, order, event: str(op.variation) if op.variation else ''
}), }),
("item_description", { ("item_description", {
"label": _("Product description"), "label": _("Product description"),
"editor_sample": _("Sample product description"), "editor_sample": _("Sample product description"),
"evaluate": lambda orderposition, order, event: escape(str(orderposition.item.description)) "evaluate": lambda orderposition, order, event: str(orderposition.item.description)
}), }),
("itemvar", { ("itemvar", {
"label": _("Product name and variation"), "label": _("Product name and variation"),
"editor_sample": _("Sample product sample variation"), "editor_sample": _("Sample product sample variation"),
"evaluate": lambda orderposition, order, event: escape(( "evaluate": lambda orderposition, order, event: (
'{} - {}'.format(orderposition.item.name, orderposition.variation) '{} - {}'.format(orderposition.item.name, orderposition.variation)
if orderposition.variation else str(orderposition.item.name) if orderposition.variation else str(orderposition.item.name)
)) )
}), }),
("item_category", { ("item_category", {
"label": _("Product category"), "label": _("Product category"),
"editor_sample": _("Ticket category"), "editor_sample": _("Ticket category"),
"evaluate": lambda orderposition, order, event: escape(( "evaluate": lambda orderposition, order, event: (
str(orderposition.item.category.name) if orderposition.item.category else "" str(orderposition.item.category.name) if orderposition.item.category else ""
)) )
}), }),
("price", { ("price", {
"label": _("Price"), "label": _("Price"),
@@ -108,12 +107,12 @@ DEFAULT_VARIABLES = OrderedDict((
("attendee_name", { ("attendee_name", {
"label": _("Attendee name"), "label": _("Attendee name"),
"editor_sample": _("John Doe"), "editor_sample": _("John Doe"),
"evaluate": lambda op, order, ev: escape(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')) "evaluate": lambda op, order, ev: op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')
}), }),
("event_name", { ("event_name", {
"label": _("Event name"), "label": _("Event name"),
"editor_sample": _("Sample event name"), "editor_sample": _("Sample event name"),
"evaluate": lambda op, order, ev: escape(str(ev.name)) "evaluate": lambda op, order, ev: str(ev.name)
}), }),
("event_date", { ("event_date", {
"label": _("Event date"), "label": _("Event date"),
@@ -189,27 +188,32 @@ DEFAULT_VARIABLES = OrderedDict((
("event_location", { ("event_location", {
"label": _("Event location"), "label": _("Event location"),
"editor_sample": _("Random City"), "editor_sample": _("Random City"),
"evaluate": lambda op, order, ev: str(ev.location).replace("\n", "<br/>\n") "evaluate": lambda op, order, ev: str(ev.location)
}), }),
("invoice_name", { ("invoice_name", {
"label": _("Invoice address name"), "label": _("Invoice address name"),
"editor_sample": _("John Doe"), "editor_sample": _("John Doe"),
"evaluate": lambda op, order, ev: escape(order.invoice_address.name if getattr(order, 'invoice_address', None) else '') "evaluate": lambda op, order, ev: order.invoice_address.name if getattr(order, 'invoice_address', None) else ''
}), }),
("invoice_company", { ("invoice_company", {
"label": _("Invoice address company"), "label": _("Invoice address company"),
"editor_sample": _("Sample company"), "editor_sample": _("Sample company"),
"evaluate": lambda op, order, ev: escape(order.invoice_address.company if getattr(order, 'invoice_address', None) else '') "evaluate": lambda op, order, ev: order.invoice_address.company if getattr(order, 'invoice_address', None) else ''
}), }),
("invoice_city", { ("invoice_city", {
"label": _("Invoice address city"), "label": _("Invoice address city"),
"editor_sample": _("Sample city"), "editor_sample": _("Sample city"),
"evaluate": lambda op, order, ev: escape(order.invoice_address.city if getattr(order, 'invoice_address', None) else '') "evaluate": lambda op, order, ev: order.invoice_address.city if getattr(order, 'invoice_address', None) else ''
}),
("attendee_company", {
"label": _("Attendee company"),
"editor_sample": _("Sample company"),
"evaluate": lambda op, order, ev: op.company or (op.addon_to.company if op.addon_to else '')
}), }),
("addons", { ("addons", {
"label": _("List of Add-Ons"), "label": _("List of Add-Ons"),
"editor_sample": _("Addon 1\nAddon 2"), "editor_sample": _("Addon 1\nAddon 2"),
"evaluate": lambda op, order, ev: "<br/>".join([ "evaluate": lambda op, order, ev: "\n".join([
'{} - {}'.format(p.item, p.variation) if p.variation else str(p.item) '{} - {}'.format(p.item, p.variation) if p.variation else str(p.item)
for p in ( for p in (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {}) op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
@@ -221,7 +225,7 @@ DEFAULT_VARIABLES = OrderedDict((
("organizer", { ("organizer", {
"label": _("Organizer name"), "label": _("Organizer name"),
"editor_sample": _("Event organizer company"), "editor_sample": _("Event organizer company"),
"evaluate": lambda op, order, ev: escape(str(order.event.organizer.name)) "evaluate": lambda op, order, ev: str(order.event.organizer.name)
}), }),
("organizer_info_text", { ("organizer_info_text", {
"label": _("Organizer info text"), "label": _("Organizer info text"),
@@ -301,7 +305,7 @@ def variables_from_questions(sender, *args, **kwargs):
if not a: if not a:
return "" return ""
else: else:
return escape(str(a)).replace("\n", "<br/>\n") return str(a)
d = {} d = {}
for q in sender.questions.all(): for q in sender.questions.all():
@@ -314,11 +318,13 @@ def variables_from_questions(sender, *args, **kwargs):
def _get_attendee_name_part(key, op, order, ev): def _get_attendee_name_part(key, op, order, ev):
return escape(op.attendee_name_parts.get(key, '')) if isinstance(key, tuple):
return ' '.join(p for p in [_get_attendee_name_part(c[0], op, order, ev) for c in key] if p)
return op.attendee_name_parts.get(key, '')
def _get_ia_name_part(key, op, order, ev): def _get_ia_name_part(key, op, order, ev):
return escape(order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else '') return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
def get_variables(event): def get_variables(event):
@@ -331,6 +337,13 @@ def get_variables(event):
'editor_sample': scheme['sample'][key], 'editor_sample': scheme['sample'][key],
'evaluate': partial(_get_attendee_name_part, key) 'evaluate': partial(_get_attendee_name_part, key)
} }
for i in range(2, len(scheme['fields']) + 1):
for comb in itertools.combinations(scheme['fields'], i):
v['attendee_name_%s' % ('_'.join(c[0] for c in comb))] = {
'label': _("Attendee name: {part}").format(part=' + '.join(str(c[1]) for c in comb)),
'editor_sample': ' '.join(str(scheme['sample'][c[0]]) for c in comb),
'evaluate': partial(_get_attendee_name_part, comb)
}
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample']) v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
v['attendee_name']['editor_sample'] = scheme['concatenation'](scheme['sample']) v['attendee_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
@@ -422,7 +435,9 @@ class Renderer:
if not o['content']: if not o['content']:
return '(error)' return '(error)'
if o['content'] == 'other': if o['content'] == 'other':
return o['text'].replace("\n", "<br/>\n") return o['text']
elif o['content'].startswith('itemmeta:'):
return op.item.meta_data.get(o['content'][9:]) or ''
elif o['content'].startswith('meta:'): elif o['content'].startswith('meta:'):
return ev.meta_data.get(o['content'][5:]) or '' return ev.meta_data.get(o['content'][5:]) or ''
elif o['content'] in self.variables: elif o['content'] in self.variables:
@@ -454,13 +469,9 @@ class Renderer:
textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255), textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
alignment=align_map[o['align']] alignment=align_map[o['align']]
) )
text = re.sub( text = conditional_escape(
"<br[^>]*>", "<br/>", self._get_text_content(op, order, o) or "",
bleach.clean( ).replace("\n", "<br/>\n")
self._get_text_content(op, order, o) or "",
tags=["br"], attributes={}, styles=[], strip=True
)
)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper # reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts. # to resolve all ligatures and python-bidi to switch RTL texts.
@@ -488,7 +499,7 @@ class Renderer:
p.drawOn(canvas, 0, -h - ad[1]) p.drawOn(canvas, 0, -h - ad[1])
canvas.restoreState() canvas.restoreState()
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition): def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True):
for o in self.layout: for o in self.layout:
if o['type'] == "barcodearea": if o['type'] == "barcodearea":
self._draw_barcodearea(canvas, op, o) self._draw_barcodearea(canvas, op, o)
@@ -498,7 +509,8 @@ class Renderer:
self._draw_poweredby(canvas, op, o) self._draw_poweredby(canvas, op, o)
if self.bg_pdf: if self.bg_pdf:
canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3])) canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3]))
canvas.showPage() if show_page:
canvas.showPage()
def render_background(self, buffer, title=_('Ticket')): def render_background(self, buffer, title=_('Ticket')):
if settings.PDFTK: if settings.PDFTK:

View File

@@ -7,7 +7,8 @@ from dateutil import parser
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
BASE_CHOICES = ( BASE_CHOICES = (
('date_from', _('Event start')), ('date_from', _('Event start')),
@@ -115,6 +116,8 @@ class RelativeDateWrapper:
base_date_name=parts[3], base_date_name=parts[3],
time=time time=time
) )
if data.base_date_name not in [k[0] for k in BASE_CHOICES]:
raise ValueError('{} is not a valid base date'.format(data.base_date_name))
else: else:
data = parser.parse(input) data = parser.parse(input)
return RelativeDateWrapper(data) return RelativeDateWrapper(data)
@@ -321,7 +324,7 @@ class ModelRelativeDateTimeField(models.CharField):
return value.to_string() return value.to_string()
return value return value
def from_db_value(self, value, expression, connection, context): def from_db_value(self, value, expression, connection):
if value is None: if value is None:
return None return None
return RelativeDateWrapper.from_string(value) return RelativeDateWrapper.from_string(value)
@@ -330,3 +333,39 @@ class ModelRelativeDateTimeField(models.CharField):
defaults = {'form_class': self.form_class} defaults = {'form_class': self.form_class}
defaults.update(kwargs) defaults.update(kwargs)
return super().formfield(**defaults) return super().formfield(**defaults)
class SerializerRelativeDateField(serializers.CharField):
def to_internal_value(self, data):
if data is None:
return None
try:
r = RelativeDateWrapper.from_string(data)
if isinstance(r.data, RelativeDate):
if r.data.time is not None:
raise ValidationError("Do not specify a time for a date field")
return r
except:
raise ValidationError("Invalid relative date")
def to_representation(self, value: RelativeDateWrapper):
if value is None:
return None
return value.to_string()
class SerializerRelativeDateTimeField(serializers.CharField):
def to_internal_value(self, data):
if data is None:
return None
try:
return RelativeDateWrapper.from_string(data)
except:
raise ValidationError("Invalid relative date")
def to_representation(self, value: RelativeDateWrapper):
if value is None:
return None
return value.to_string()

View File

@@ -0,0 +1,224 @@
import logging
from decimal import Decimal
from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
from i18nfield.strings import LazyI18nString
from pretix.base.decimal import round_decimal
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import (
Event, InvoiceAddress, Order, OrderFee, OrderPosition, OrderRefund,
SubEvent, User, WaitingListEntry,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException, TolerantDict, mail
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
)
from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app
logger = logging.getLogger(__name__)
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
with language(wle.locale):
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
try:
mail(
wle.email,
str(subject).format_map(TolerantDict(email_context)),
message,
email_context,
wle.event,
locale=wle.locale
)
except SendMailException:
logger.exception('Waiting list canceled email could not be sent')
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
refund_amount: Decimal, user: User, positions: list):
with language(order.locale):
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress()
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event)
real_subject = str(subject).format_map(TolerantDict(email_context))
try:
order.send_mail(
real_subject, message, email_context,
'pretix.event.order.email.event_canceled',
user,
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in positions:
if subevent and p.subevent_id != subevent.id:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
real_subject = str(subject).format_map(TolerantDict(email_context))
email_context = get_email_context(event_or_subevent=subevent or order.event,
event=order.event,
refund_amount=refund_amount,
position_or_address=p,
order=order, position=p)
try:
order.send_mail(
real_subject, message, email_context,
'pretix.event.order.email.event_canceled',
position=p,
user=user
)
except SendMailException:
logger.exception('Order canceled email could not be sent to attendee')
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
keep_fee_percentage: str, keep_fees: list=None, manual_refund: bool=False,
send: bool=False, send_subject: dict=None, send_message: dict=None,
send_waitinglist: bool=False, send_waitinglist_subject: dict={}, send_waitinglist_message: dict={},
user: int=None, refund_as_giftcard: bool=False):
send_subject = LazyI18nString(send_subject)
send_message = LazyI18nString(send_message)
send_waitinglist_subject = LazyI18nString(send_waitinglist_subject)
send_waitinglist_message = LazyI18nString(send_waitinglist_message)
if user:
user = User.objects.get(pk=user)
s = OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(k=Count('id')).values('k')
orders_to_cancel = event.orders.annotate(pcnt=Subquery(s, output_field=IntegerField())).filter(
status__in=[Order.STATUS_PAID, Order.STATUS_PENDING, Order.STATUS_EXPIRED],
pcnt__gt=0
).all()
if subevent:
subevent = event.subevents.get(pk=subevent)
has_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).filter(
subevent=subevent
)
has_other_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).exclude(
subevent=subevent
)
orders_to_change = orders_to_cancel.annotate(
has_subevent=Exists(has_subevent),
has_other_subevent=Exists(has_other_subevent),
).filter(
has_subevent=True, has_other_subevent=True
)
orders_to_cancel = orders_to_cancel.annotate(
has_subevent=Exists(has_subevent),
has_other_subevent=Exists(has_other_subevent),
).filter(
has_subevent=True, has_other_subevent=False
)
subevent.log_action(
'pretix.subevent.canceled', user=user,
)
subevent.active = False
subevent.save(update_fields=['active'])
subevent.log_action(
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
else:
orders_to_change = event.orders.none()
event.log_action(
'pretix.event.canceled', user=user,
)
for i in event.items.filter(active=True):
i.active = False
i.save(update_fields=['active'])
i.log_action(
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
failed = 0
for o in orders_to_cancel.only('id', 'total'):
try:
fee = Decimal('0.00')
fee_sum = Decimal('0.00')
keep_fee_objects = []
if keep_fees:
for f in o.fees.all():
if f.fee_type in keep_fees:
fee += f.value
keep_fee_objects.append(f)
fee_sum += f.value
if keep_fee_percentage:
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * (o.total - fee_sum)
if keep_fee_fixed:
fee += Decimal(keep_fee_fixed)
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
refund_amount = o.payment_refund_sum
try:
if auto_refund:
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard)
finally:
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
except LockTimeoutException:
logger.exception("Could not cancel order")
failed += 1
except OrderError:
logger.exception("Could not cancel order")
failed += 1
for o in orders_to_change.values_list('id', flat=True):
with transaction.atomic():
o = event.orders.select_for_update().get(pk=o)
total = Decimal('0.00')
positions = []
ocm = OrderChangeManager(o, user=user, notify=False)
for p in o.positions.all():
if p.subevent == subevent:
total += p.price
ocm.cancel(p)
positions.append(p)
fee = Decimal('0.00')
if keep_fee_fixed:
fee += Decimal(keep_fee_fixed)
if keep_fee_percentage:
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
if fee:
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=fee,
order=o,
tax_rule=o.event.settings.tax_rate_default,
)
f._calculate_tax()
ocm.add_fee(f)
ocm.commit()
refund_amount = o.payment_refund_sum - o.total
if auto_refund:
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True, source=OrderRefund.REFUND_SOURCE_ADMIN)
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
for wle in event.waitinglistentries.filter(subevent=subevent, voucher__isnull=True):
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, subevent)
return failed

View File

@@ -9,7 +9,7 @@ from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, OuterRef, Q from django.db.models import Count, Exists, OuterRef, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext as _ from django.utils.translation import gettext as _, pgettext_lazy
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
@@ -213,7 +213,7 @@ class CartManager:
has_variations=Count('variations'), has_variations=Count('variations'),
).filter( ).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache] id__in=[i for i in item_ids if i and i not in self._items_cache]
) ).order_by()
}) })
self._variations_cache.update({ self._variations_cache.update({
v.pk: v v.pk: v
@@ -221,7 +221,7 @@ class CartManager:
'quotas' 'quotas'
).select_related('item', 'item__event').filter( ).select_related('item', 'item__event').filter(
id__in=[i for i in variation_ids if i and i not in self._variations_cache] id__in=[i for i in variation_ids if i and i not in self._variations_cache]
) ).order_by()
}) })
def _check_max_cart_size(self): def _check_max_cart_size(self):
@@ -303,32 +303,6 @@ class CartManager:
if op.item.require_bundling and not op.addon_to == 'FAKE': if op.item.require_bundling and not op.addon_to == 'FAKE':
raise CartError(error_messages['bundled_only']) raise CartError(error_messages['bundled_only'])
if op.item.max_per_order or op.item.min_per_order:
new_total = (
len([1 for p in self.positions if p.item_id == op.item.pk]) +
sum([_op.count for _op in self._operations + current_ops
if isinstance(_op, self.AddOperation) and _op.item == op.item]) +
op.count -
len([1 for _op in self._operations + current_ops
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
)
if op.item.max_per_order and new_total > op.item.max_per_order:
raise CartError(
_(error_messages['max_items_per_product']) % {
'max': op.item.max_per_order,
'product': op.item.name
}
)
if op.item.min_per_order and new_total < op.item.min_per_order:
raise CartError(
_(error_messages['min_items_per_product']) % {
'min': op.item.min_per_order,
'product': op.item.name
}
)
def _get_price(self, item: Item, variation: Optional[ItemVariation], def _get_price(self, item: Item, variation: Optional[ItemVariation],
voucher: Optional[Voucher], custom_price: Optional[Decimal], voucher: Optional[Voucher], custom_price: Optional[Decimal],
subevent: Optional[SubEvent], cp_is_net: bool=None, force_custom_price=False, subevent: Optional[SubEvent], cp_is_net: bool=None, force_custom_price=False,
@@ -787,37 +761,48 @@ class CartManager:
return vouchers_ok return vouchers_ok
def _check_min_per_product(self): def _check_min_max_per_product(self):
per_product = Counter() items = Counter()
min_per_product = {}
for p in self.positions: for p in self.positions:
per_product[p.item_id] += 1 items[p.item] += 1
min_per_product[p.item.pk] = p.item.min_per_order
for op in self._operations: for op in self._operations:
if isinstance(op, self.AddOperation): if isinstance(op, self.AddOperation):
per_product[op.item.pk] += op.count items[op.item] += op.count
min_per_product[op.item.pk] = op.item.min_per_order
elif isinstance(op, self.RemoveOperation): elif isinstance(op, self.RemoveOperation):
per_product[op.position.item_id] -= 1 items[op.position.item] -= 1
min_per_product[op.position.item.pk] = op.position.item.min_per_order
err = None err = None
for itemid, num in per_product.items(): for item, count in items.items():
min_p = min_per_product[itemid] if count == 0:
if min_p and num < min_p: continue
if item.max_per_order and count > item.max_per_order:
raise CartError(
_(error_messages['max_items_per_product']) % {
'max': item.max_per_order,
'product': item.name
}
)
if item.min_per_order and count < item.min_per_order:
self._operations = [o for o in self._operations if not ( self._operations = [o for o in self._operations if not (
isinstance(o, self.AddOperation) and o.item.pk == itemid isinstance(o, self.AddOperation) and o.item.pk == item.pk
)] )]
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)] removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
for p in self.positions: for p in self.positions:
if p.item_id == itemid and p.pk not in removals: if p.item_id == item.pk and p.pk not in removals:
self._operations.append(self.RemoveOperation(position=p)) self._operations.append(self.RemoveOperation(position=p))
err = _(error_messages['min_items_per_product_removed']) % { err = _(error_messages['min_items_per_product_removed']) % {
'min': min_p, 'min': item.min_per_order,
'product': p.item.name 'product': item.name
} }
if not err:
raise CartError(
_(error_messages['min_items_per_product']) % {
'min': item.min_per_order,
'product': item.name
}
)
return err return err
def _perform_operations(self): def _perform_operations(self):
@@ -826,7 +811,7 @@ class CartManager:
err = None err = None
new_cart_positions = [] new_cart_positions = []
err = err or self._check_min_per_product() err = err or self._check_min_max_per_product()
self._operations.sort(key=lambda a: self.order[type(a)]) self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set() seats_seen = set()

View File

@@ -2,12 +2,12 @@ from django.db import transaction
from django.db.models import Prefetch from django.db.models import Prefetch
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext as _ from django.utils.translation import gettext as _
from pretix.base.models import ( from pretix.base.models import (
Checkin, CheckinList, Order, OrderPosition, Question, QuestionOption, Checkin, CheckinList, Order, OrderPosition, Question, QuestionOption,
) )
from pretix.base.signals import order_placed from pretix.base.signals import checkin_created, order_placed
class CheckInError(Exception): class CheckInError(Exception):
@@ -143,6 +143,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'datetime': dt, 'datetime': dt,
'list': clist.pk 'list': clist.pk
}, user=user, auth=auth) }, user=user, auth=auth)
checkin_created.send(op.order.event, checkin=ci)
else: else:
if not force: if not force:
raise CheckInError( raise CheckInError(
@@ -171,4 +172,5 @@ def order_placed(sender, **kwargs):
for op in order.positions.all(): for op in order.positions.all():
for cl in cls: for cl in cls:
if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}: if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}:
Checkin.objects.create(position=op, list=cl, auto_checked_in=True) ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True)
checkin_created.send(event, checkin=ci)

View File

@@ -2,7 +2,7 @@ from typing import Any, Dict
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.timezone import override from django.utils.timezone import override
from django.utils.translation import ugettext from django.utils.translation import gettext
from pretix.base.i18n import LazyLocaleException, language from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import CachedFile, Event, cachedfile_name from pretix.base.models import CachedFile, Event, cachedfile_name
@@ -26,7 +26,7 @@ def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any])
d = ex.render(form_data) d = ex.render(form_data)
if d is None: if d is None:
raise ExportError( raise ExportError(
ugettext('Your export did not contain any data.') gettext('Your export did not contain any data.')
) )
file.filename, file.type, data = d file.filename, file.type, data = d
file.file.save(cachedfile_name(file, file.filename), ContentFile(data)) file.file.save(cachedfile_name(file, file.filename), ContentFile(data))

View File

@@ -15,7 +15,7 @@ from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _ from django.utils.translation import gettext as _, pgettext
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
@@ -37,6 +37,10 @@ logger = logging.getLogger(__name__)
@transaction.atomic @transaction.atomic
def build_invoice(invoice: Invoice) -> Invoice: def build_invoice(invoice: Invoice) -> Invoice:
invoice.locale = invoice.event.settings.get('invoice_language', invoice.event.settings.locale)
if invoice.locale == '__user__':
invoice.locale = invoice.order.locale or invoice.event.settings.locale
lp = invoice.order.payments.last() lp = invoice.order.payments.last()
with language(invoice.locale): with language(invoice.locale):
@@ -85,6 +89,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
).split("\n") if a.strip() ).split("\n") if a.strip()
) )
invoice.internal_reference = ia.internal_reference invoice.internal_reference = ia.internal_reference
invoice.custom_field = ia.custom_field
invoice.invoice_to_company = ia.company invoice.invoice_to_company = ia.company
invoice.invoice_to_name = ia.name invoice.invoice_to_name = ia.name
invoice.invoice_to_street = ia.street invoice.invoice_to_street = ia.street
@@ -249,17 +254,11 @@ def regenerate_invoice(invoice: Invoice):
def generate_invoice(order: Order, trigger_pdf=True): def generate_invoice(order: Order, trigger_pdf=True):
locale = order.event.settings.get('invoice_language', order.event.settings.locale)
if locale:
if locale == '__user__':
locale = order.locale or order.event.settings.locale
invoice = Invoice( invoice = Invoice(
order=order, order=order,
event=order.event, event=order.event,
organizer=order.event.organizer, organizer=order.event.organizer,
date=timezone.now().date(), date=timezone.now().date(),
locale=locale
) )
invoice = build_invoice(invoice) invoice = build_invoice(invoice)
if trigger_pdf: if trigger_pdf:
@@ -313,7 +312,7 @@ def build_preview_invoice_pdf(event):
with rolledback_transaction(), language(locale): with rolledback_transaction(), language(locale):
order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(), order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(),
expires=timezone.now(), code="PREVIEW", total=119) expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count())
invoice = Invoice( invoice = Invoice(
order=order, event=event, invoice_no="PREVIEW", order=order, event=event, invoice_no="PREVIEW",
date=timezone.now().date(), locale=locale, organizer=event.organizer date=timezone.now().date(), locale=locale, organizer=event.organizer
@@ -351,7 +350,7 @@ def build_preview_invoice_pdf(event):
if event.tax_rules.exists(): if event.tax_rules.exists():
for i, tr in enumerate(event.tax_rules.all()): for i, tr in enumerate(event.tax_rules.all()):
tax = tr.tax(Decimal('100.00')) tax = tr.tax(Decimal('100.00'), base_price_is='gross')
InvoiceLine.objects.create( InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product {}").format(i + 1), invoice=invoice, description=_("Sample product {}").format(i + 1),
gross_value=tax.gross, tax_value=tax.tax, gross_value=tax.gross, tax_value=tax.tax,

View File

@@ -20,7 +20,7 @@ from django.core.mail import (
) )
from django.core.mail.message import SafeMIMEText from django.core.mail.message import SafeMIMEText
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.translation import pgettext, ugettext as _ from django.utils.translation import gettext as _, pgettext
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
@@ -126,7 +126,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
renderer = ClassicMailRenderer(None) renderer = ClassicMailRenderer(None)
content_plain = body_plain = render_mail(template, context) content_plain = body_plain = render_mail(template, context)
subject = str(subject).format_map(TolerantDict(context)) subject = str(subject).format_map(TolerantDict(context))
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) or settings.MAIL_FROM
if event: if event:
sender_name = event.settings.mail_from_name or str(event.name) sender_name = event.settings.mail_from_name or str(event.name)
sender = formataddr((sender_name, sender)) sender = formataddr((sender_name, sender))
@@ -276,20 +276,6 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
cm = lambda: scopes_disabled() # noqa cm = lambda: scopes_disabled() # noqa
with cm(): with cm():
if invoices:
invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices:
if inv.file:
try:
with language(inv.order.locale):
email.attach(
pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf',
inv.file.file.read(),
'application/pdf'
)
except:
logger.exception('Could not attach invoice to email')
pass
if event: if event:
if order: if order:
try: try:
@@ -344,6 +330,21 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user) email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
if invoices:
invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices:
if inv.file:
try:
with language(inv.order.locale):
email.attach(
pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf',
inv.file.file.read(),
'application/pdf'
)
except:
logger.exception('Could not attach invoice to email')
pass
email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order) email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order)
try: try:

View File

@@ -1,5 +1,6 @@
from django.conf import settings from django.conf import settings
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.timezone import override
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from inlinestyler.utils import inline_css from inlinestyler.utils import inline_css
@@ -79,7 +80,7 @@ def send_notification(logentry_id: int, action_type: str, user_id: int, method:
if not notification_type: if not notification_type:
return # Ignore, e.g. plugin not active for this event return # Ignore, e.g. plugin not active for this event
with language(user.locale): with language(user.locale), override(logentry.event.timezone if logentry.event else user.timezone):
notification = notification_type.build_notification(logentry) notification = notification_type.build_notification(logentry)
if method == "mail": if method == "mail":

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