Compare commits

..

267 Commits

Author SHA1 Message Date
Raphael Michel
d29b8eba01 Bump to 3.5.0 2020-01-12 14:42:57 +01:00
Raphael Michel
f566b353f2 Retry more email failures 2020-01-11 14:11:20 +01:00
Raphael Michel
c2221fad32 Update from Weblate (#1544)
Update from Weblate
2020-01-11 14:00:18 +01:00
Maarten van den Berg
06a8a804f4 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3360 of 3360 strings)

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

powered by weblate
2020-01-11 12:54:54 +00:00
Raphael Michel
5832429540 Fix unrecognized user agents seen in the wild 2020-01-11 13:52:37 +01:00
Raphael Michel
7913de971c Fix timezone assumptions in test fixtures 2020-01-11 13:45:49 +01:00
Raphael Michel
9b91e3e4f6 Fix doc typos 2020-01-11 13:34:11 +01:00
Raphael Michel
402730df43 API: Add timezone attribute to events
Note: I still believe the issues described in https://github.com/pretix/pretix/issues/1378
are a problem, and I'm still not keen on adding settings properties to
the API until we have a proper design for it. However, I'm making an
exception here since the list of events can't be used in a very useful
way with not access to the timezone.
2020-01-11 13:24:19 +01:00
Raphael Michel
c1d6d9bf1a Downgrade openpyxl since we still support Python 3.5 2020-01-11 13:15:58 +01:00
Raphael Michel
ddbe27f351 API: Allow to return canceled positions and fees 2020-01-11 12:57:43 +01:00
Raphael Michel
35f2b10069 Compatibility with new openpyxl 2020-01-11 12:12:53 +01:00
Raphael Michel
0d2a534982 Bank transfer: Show date of last import 2020-01-09 21:41:08 +01:00
Raphael Michel
a1ad00a30c Add anchors for products/categories 2020-01-09 10:19:44 +01:00
Raphael Michel
07d2463960 Correctly set timezone when cloning events 2020-01-09 09:19:11 +01:00
Raphael Michel
08de722525 Prevent showing internal product name in question step 2020-01-09 09:19:11 +01:00
Raphael Michel
891e740ede Update from Weblate (#1541)
Update from Weblate
2020-01-08 16:52:20 +01:00
pajowu
87645a0b1f Limit Payment Term Days Field (#1542) 2020-01-08 16:52:02 +01:00
oocf
d7a575683e Translated on translate.pretix.eu (Spanish)
Currently translated at 91.6% (3077 of 3360 strings)

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

powered by weblate
2020-01-08 12:17:00 +00:00
Raphael Michel
ca83a44489 PDF Editor: Fix compatibility problems with older text objects 2020-01-08 13:16:42 +01:00
Raphael Michel
40da03f979 Improve text rendering options in PDF editor (#1540)
Improve text rendering options in PDF editor
2020-01-07 12:23:37 +01:00
Raphael Michel
fdd45a85f0 Do not send download reminders if order is placed after download date 2020-01-07 11:54:06 +01:00
Raphael Michel
47579d0517 Fix #505 -- Allow to let text flow downwards 2020-01-07 11:48:04 +01:00
Raphael Michel
8704a7f3dd Fix #1053 -- Rotation support in reportlab renderer 2020-01-07 11:26:28 +01:00
Raphael Michel
244e0695b1 PDF editor: Do not catch keyboard events inside source view 2020-01-03 18:30:33 +01:00
Raphael Michel
8e2821b398 Add a maximum budget to vouchers (#1526)
* Data model changes

* Fix test failures

* Adjustments

* Some tests and API support

* Check when extending orders

* Make things more deterministic, fix style

* Do not apply negative discounts

* Update price_before_voucher on item/subevent changes

* Add tests for price_before_voucher in combination with free price

* Fix InvoiceAddress.DoesNotExist
2020-01-03 16:15:17 +01:00
Raphael Michel
b738e3bd9d Do not show canceled payment to user 2020-01-03 10:48:57 +01:00
Raphael Michel
fa224fd17e Allow to use invoice address city in PDFs 2020-01-03 10:47:53 +01:00
Raphael Michel
76359c859f Improve logging of gift card changes and show logs 2020-01-02 17:40:11 +01:00
Katharina Bogad
ff98ae3200 Update oauthlib to ==3.1.* (#1538)
* Bumped oauthlib to ==3.1.*

* Fix tests for oauthlib 3.1

In some cases, oauthlib now returns 400 instead of 401.
2020-01-02 13:15:24 +01:00
Raphael Michel
2ffb4edee9 Import: Do not set country to NULL
PRETIXEU-1QJ
2020-01-02 10:53:01 +01:00
Raphael Michel
902f589ee6 Update from Weblate (#1536)
Update from Weblate
2020-01-02 10:11:49 +01:00
Raphael Michel
2a6dc22d7b Allow to use datetime components in invoice prefixes (#1529) 2020-01-02 09:46:07 +01:00
Abdullah
4d9ec7c8e4 Translated on translate.pretix.eu (Arabic)
Currently translated at 97.1% (100 of 103 strings)

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

powered by weblate
2019-12-25 19:33:08 +00:00
Abdullah
b84a0e93ae Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3310 of 3359 strings)

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

powered by weblate
2019-12-25 19:33:08 +00:00
Abdullah
534f09bdc6 Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3310 of 3359 strings)

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

powered by weblate
2019-12-25 19:33:08 +00:00
Patrick Arminio
370aa63ead Translated on translate.pretix.eu (Italian)
Currently translated at 47.6% (49 of 103 strings)

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

powered by weblate
2019-12-25 19:33:08 +00:00
Raphael Michel
fb7e859e72 Ticket download: Fix error message representation 2019-12-25 20:32:22 +01:00
Raphael Michel
80a7c45e05 Fix missing checks in 38a19bb58 2019-12-25 20:26:47 +01:00
Raphael Michel
38a19bb58b Allow to download tickets through GET requests 2019-12-23 22:07:32 +01:00
Patrick Arminio
19873e2a09 Expose help texts on questions' API (#1534)
* Expose help texts on questions' API

* Update questions docs to show help_text

* Update questions.rst

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2019-12-22 19:30:17 +01:00
Raphael Michel
eb7e938af6 Allow to explicitly set ticket language 2019-12-20 18:17:00 +01:00
Raphael Michel
614c40596f Move revoked devices to the bottom in the list of devices 2019-12-19 09:22:42 +01:00
Raphael Michel
4db56e218e Update from Weblate (#1530)
Update from Weblate
2019-12-19 08:56:49 +01:00
Abdullah
ed9b96a41c Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3310 of 3359 strings)

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

powered by weblate
2019-12-19 02:00:10 +00:00
Raphael Michel
e839dbc7d4 Emulate secure request in samesite tests 2019-12-18 19:37:43 +01:00
Raphael Michel
982fb0149d Never set SameSite=None without HTTPS 2019-12-18 19:03:17 +01:00
Raphael Michel
4597cb9849 Drop typeahead for seats 2019-12-18 13:48:15 +01:00
Raphael Michel
9f629fc1c9 Do not show subject prefix in HTML email headline 2019-12-17 11:09:05 +01:00
Raphael Michel
387e1b4998 Fix issue that blocks seat sold in canceled position 2019-12-16 14:00:08 +01:00
Raphael Michel
84415864e5 Note cloning of objects to log 2019-12-16 14:00:08 +01:00
Maico Timmerman
82feca6e38 Fix #1521 -- External authenticated users cannot delete events (#1523)
* Remove check password for event deletion, instead require recent login.

* Reauthenticate for backends using authentication_url.

* Require recent login for data shredder and prompt slug instead of password.

* Fix tests for recent login required on event delete and data shred.

* Pull request remarks for recent login required for event delete and data shred.

* Remove unused imported check_password.
2019-12-16 10:45:01 +01:00
Maico Timmerman
28242e52aa Fix #1522 -- Login button redirects to authentication url with… (#1525) 2019-12-16 10:42:51 +01:00
Raphael Michel
488ee19b11 Set direction:rtl in emails 2019-12-16 10:35:54 +01:00
Raphael Michel
ba4f00cfc0 Update from Weblate (#1520)
Update from Weblate
2019-12-16 10:33:56 +01:00
Maarten van den Berg
eb392ebf20 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-12-16 08:54:34 +00:00
Maarten van den Berg
3cef690779 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3360 of 3360 strings)

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

powered by weblate
2019-12-16 08:54:34 +00:00
Abdullah
294fc4735a Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3310 of 3359 strings)

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

powered by weblate
2019-12-16 08:54:34 +00:00
Abdullah
6d17cad529 Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3310 of 3359 strings)

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

powered by weblate
2019-12-16 08:54:34 +00:00
Abdullah
668380cc3f Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3310 of 3359 strings)

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

powered by weblate
2019-12-16 08:54:34 +00:00
Abdullah
3bf8de39a0 Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3310 of 3359 strings)

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

powered by weblate
2019-12-16 08:54:34 +00:00
saad91
1986cdf4b9 Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3310 of 3359 strings)

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

powered by weblate
2019-12-16 08:54:34 +00:00
Raphael Michel
577729a271 Add customer data to OrderTaxListReport 2019-12-16 09:54:16 +01:00
Raphael Michel
9033e5b6f7 Propose a gift card code when creating new cards 2019-12-16 09:45:36 +01:00
Raphael Michel
c1fa0d1559 Fix #1524 -- Bug in CartManager's max_per_item validation 2019-12-16 09:45:36 +01:00
Raphael Michel
e1a4dd6e43 Revert "Data model changes"
This reverts commit 089a468a5d.
2019-12-15 19:00:02 +01:00
Raphael Michel
089a468a5d Data model changes 2019-12-15 18:28:51 +01:00
Raphael Michel
018d345008 Add trust-x-forwarded-proto settings 2019-12-14 13:37:44 +01:00
Raphael Michel
529e2a0286 Re-set redeemed property when copying vouchers 2019-12-13 15:52:27 +01:00
Raphael Michel
1d36ef3c24 Allow to ignore plugin conflicts via environment 2019-12-13 12:57:25 +01:00
Raphael Michel
282ad2c869 Do not check compatibiliy while upgrading 2019-12-13 12:55:33 +01:00
Raphael Michel
e67ff83378 Do not allow to create negative gift cards through the API 2019-12-12 14:18:47 +01:00
Raphael Michel
21be22e489 Add typeahead when filling event meta parameters 2019-12-12 12:21:53 +01:00
Raphael Michel
3da79ad36b Remove positivity constraint in apply_voucher 2019-12-12 12:02:12 +01:00
Raphael Michel
8a17fedaa6 Clarify show_vouchers constraint with subevents 2019-12-12 11:56:36 +01:00
Raphael Michel
7d6b3e7140 Set sales channel on all cart operations 2019-12-12 10:06:00 +01:00
Raphael Michel
f80ba365a5 Fix test for voucher csv 2019-12-11 18:13:38 +01:00
Raphael Michel
8156cdd539 Add seat to voucher CSV export 2019-12-11 17:30:53 +01:00
Raphael Michel
cd55146867 Add request and subevent parameters to front page widgets 2019-12-11 17:05:05 +01:00
Raphael Michel
3cb7482bae Add signal pretix.presale.signals.front_page_bottom_widget 2019-12-11 17:04:59 +01:00
Raphael Michel
99f3db04a9 Allow to redeem a voucher for an existing cart (#1517)
* Allow to redeem a voucher for an existing cart

* Bundle behaviour
2019-12-11 15:58:22 +01:00
Raphael Michel
352942b7d6 Allow sale of blocked seats on specific channels (#1518)
* Allow sale of blocked seats on specific channels

* Add docs
2019-12-11 15:56:20 +01:00
Raphael Michel
6d3ccc0182 More useful titles in select2 widget for seats 2019-12-11 13:05:04 +01:00
Raphael Michel
49b73fc096 Fix redemption of all-product vouchers with seating plans 2019-12-11 12:06:23 +01:00
Raphael Michel
24b931e1c3 Allow to import orders (#1516)
* Allow to import orders

* seats, subevents

* Plugin support

* Add docs

* Warn about lack of quota handling

* Control interface test

* Test skeleton

* First tests for the impotr columns

* Add tests for all columns

* Fix question validation
2019-12-11 11:44:06 +01:00
Raphael Michel
1c99e01af9 Fix test failure introduced in last commit 2019-12-11 11:22:52 +01:00
Raphael Michel
66183e805e Check-in list export: Filter by checkin_attention 2019-12-11 09:16:58 +01:00
Raphael Michel
d33c9332c6 Show event time in list of events on organizer page 2019-12-11 09:11:54 +01:00
Raphael Michel
2284def607 Fix package name of dependency 2019-12-10 18:10:29 +01:00
Raphael Michel
15c25a5a0d PDF renderer: Support for arabic 2019-12-10 17:57:48 +01:00
Martin Gross
cf5ac6af4b Organizer-level override for giftcard code length 2019-12-09 13:55:26 +01:00
Raphael Michel
2a929200b5 Vouchers: Fix CSV export for all-product vouchers 2019-12-09 10:23:50 +01:00
Raphael Michel
3f77d34026 Use the arab league flag to represent Arabic, for now
https://www.quora.com/Which-flag-represents-the-Arabic-language
http://www.flagsarenotlanguages.com/blog/the-arab-league-flag-for-arabic-language/
2019-12-07 15:30:14 +01:00
Raphael Michel
a395b24b80 Add arabic, re-order languages 2019-12-07 15:08:57 +01:00
Raphael Michel
984ef60099 Fix issues in translation 2019-12-07 15:06:15 +01:00
saad91
5b6f0df963 Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3309 of 3359 strings)

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

powered by weblate
2019-12-07 13:56:30 +00:00
saad91
509c7d98cc Translated on translate.pretix.eu (Arabic)
Currently translated at 97.4% (3271 of 3359 strings)

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

powered by weblate
2019-12-07 13:20:50 +00:00
saad91
3bd4959efe Translated on translate.pretix.eu (Arabic)
Currently translated at 97.4% (3271 of 3359 strings)

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

powered by weblate
2019-12-07 13:20:50 +00:00
Maarten van den Berg
4faaa8e521 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-12-07 13:20:49 +00:00
Maarten van den Berg
0e8832fd54 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3360 of 3360 strings)

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

powered by weblate
2019-12-07 13:20:49 +00:00
Raphael Michel
4faa76d9c7 Translated on translate.pretix.eu (Arabic)
Currently translated at 98.5% (3274 of 3323 strings)

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

powered by weblate
2019-12-07 13:20:49 +00:00
Raphael Michel
8d1f9bf0f3 Fix TypeError during widget loading 2019-12-07 14:18:04 +01:00
Raphael Michel
4afef62cbd Fix typo 2019-12-07 13:24:35 +01:00
Raphael Michel
3d5cfdd9c7 Fix conflict between are-you-sure.js and async tasks
Thanks @luto for reporting and helping to debug
2019-12-07 13:23:33 +01:00
Raphael Michel
b3b1d09690 Use "Tax Invoice" as the invoice headline in Australia 2019-12-07 12:12:20 +01:00
Raphael Michel
381ecd6d75 Update German translation 2019-12-06 20:47:51 +01:00
Raphael Michel
a12fea71e5 Include expire date on invoices 2019-12-06 20:43:01 +01:00
Raphael Michel
a6dd6ac537 Fix AttributeError in e275677a0 2019-12-06 20:31:17 +01:00
Raphael Michel
c3041aa8c4 Fix ItemBundle.MultipleObjectsReturned error when extending cart lifetimes 2019-12-06 20:30:35 +01:00
Raphael Michel
e275677a0a Default to modern invoice renderer for new events 2019-12-06 20:25:05 +01:00
Raphael Michel
fff14c31ba Add Event.set_defaults 2019-12-06 20:24:34 +01:00
Raphael Michel
a74bde60eb Show invoice address form once again before generating a new invoice 2019-12-06 20:03:22 +01:00
Raphael Michel
12b9d23efb Hide "Generate invoice" button if no payment method is selected 2019-12-06 20:03:11 +01:00
Raphael Michel
afec39ce57 Fix exception when submitting cart positions with invalid subevent IDs 2019-12-06 15:58:13 +01:00
Raphael Michel
4ae22c4a1e Bump to 3.5.0.dev0 2019-12-06 15:36:27 +01:00
Raphael Michel
a928d8c781 Bump version to 3.4.0 2019-12-06 15:35:44 +01:00
Raphael Michel
27b7a756fb Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-12-06 15:07:44 +01:00
Raphael Michel
7252167cc7 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-12-06 15:07:44 +01:00
Raphael Michel
d6ac337bde Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3360 of 3360 strings)

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

powered by weblate
2019-12-06 15:07:44 +01:00
Raphael Michel
aa9d3ab614 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3360 of 3360 strings)

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

powered by weblate
2019-12-06 15:07:44 +01:00
Martin Gross
186fb851e6 Translated on translate.pretix.eu (Russian)
Currently translated at 33.5% (1127 of 3360 strings)

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

powered by weblate
2019-12-06 13:07:26 +01:00
Martin Gross
97f7507dad Translated on translate.pretix.eu (Latvian)
Currently translated at 33.5% (1124 of 3360 strings)

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

powered by weblate
2019-12-06 13:07:19 +01:00
Martin Gross
e4582dd9ed Add itemcount (Items, excluding AddOns) to fragment_cart (#1514)
* Add itemcount (Items, excluding AddOns) to fragment_cart

* Simplify itemcount
2019-12-05 16:45:41 +01:00
Raphael Michel
090d5bfe94 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-12-05 14:34:15 +01:00
Martin Gross
b4e98e21a1 Translated on translate.pretix.eu (Russian)
Currently translated at 33.8% (1124 of 3323 strings)

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

powered by weblate
2019-12-05 14:20:29 +01:00
Carolina Fernández
4571abe496 Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-12-05 14:20:20 +01:00
Carolina Fernández
f86520af37 Translated on translate.pretix.eu (Spanish)
Currently translated at 92.5% (3074 of 3323 strings)

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

powered by weblate
2019-12-05 14:20:15 +01:00
Jaroslav Lichtblau
1a5b8a2e07 Translated on translate.pretix.eu (Czech)
Currently translated at 0.2% (8 of 3323 strings)

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

powered by weblate
2019-12-05 14:20:07 +01:00
Maico Timmerman
dddf91d3bf Feature/optional team provisioning (#1487)
* Event creation: Add option to select existing team instead of provisioning.

* Event creation: disallow event team provisioning on organizer level.

* Event team provisioning: update default setting location and display strings.

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

Co-Authored-By: Raphael Michel <mail@raphaelmichel.de>
2019-12-05 12:20:40 +01:00
Martin Gross
f88a09a78c Update reportlab requirement 2019-12-05 10:43:38 +01:00
Raphael Michel
adebcc31d4 Call order_paid when creating paid orders through the API 2019-12-05 10:02:46 +01:00
Raphael Michel
5645a07cdc Email design: Make height of separator more reliable 2019-12-05 10:02:27 +01:00
Martin Gross
1d732e9675 Update pillow requirement from 5.* to 6.* 2019-12-04 18:47:18 +01:00
Martin Gross
61de010bbb Put customer VAT ID in the same line as the descriptor 2019-12-04 17:55:13 +01:00
Martin Gross
22eb9d3493 Add "Are you sure?"-dialog to dirty forms (#1506)
* Add "Are you sure?"-dialog to dirty forms

* Better form detection
2019-12-04 13:59:08 +01:00
Raphael Michel
4e8bda0c96 Improve email contents (#1510)
Improve email contents
2019-12-04 13:58:20 +01:00
Raphael Michel
0c83e5a50c Use a narrower separator PNG to reduce wrong-looking area in dark mode 2019-12-04 12:23:45 +01:00
Raphael Michel
745d7f74a1 Fix a display issue in GMail 2019-12-04 11:56:14 +01:00
Raphael Michel
8c86169d3f Fix #1415 -- Proper MIME structure 2019-12-04 11:56:02 +01:00
Raphael Michel
3c87272fdc Bank transfer import: Fix crash when no date column is selected 2019-12-04 10:29:26 +01:00
Raphael Michel
f1142560f6 Fix crash with specific chromium user agents 2019-12-04 10:29:18 +01:00
Raphael Michel
d46278f04f Set cookies with SameSite=None if possible (#1509) 2019-12-03 14:50:18 +01:00
Raphael Michel
098b7363e6 Waren about products with hidden_if_available 2019-12-01 12:34:47 +01:00
Raphael Michel
11d0c37415 Try to discourage accidentally setting Item.hidden_if_available 2019-12-01 12:31:09 +01:00
Raphael Michel
99607aa74a Fix AttributeError in sendmail form
PRETIXEU-1NQ
2019-12-01 11:17:30 +01:00
Raphael Michel
d44b75388e RelativeDate: Fix parsing of invalid strings
PRETIXEU-1NH
2019-11-29 10:29:53 +01:00
Raphael Michel
531c8aedc2 Remove logging statement 2019-11-28 16:32:02 +01:00
Martin Gross
0474651070 Add unlimited_items_per_order-flag to Sales Channels (#1508)
* Add unlimited_items_per_order-flag to Sales Channels

* Test for unlimited_items_per_order Sales Channels-flag

* Fix test
2019-11-28 16:31:38 +01:00
Raphael Michel
fd7ad3cb16 Fix docs typo 2019-11-28 15:10:26 +01:00
Raphael Michel
d5b932e0d9 Add data-fix option to widget 2019-11-28 14:26:14 +01:00
Raphael Michel
5462e256ac Allow to create a new gift card when refunding 2019-11-28 13:50:10 +01:00
Raphael Michel
46d4d97c13 Add BasePaymentProvider.refund_control_render 2019-11-28 13:50:10 +01:00
Raphael Michel
8b5241d520 Improve error messages when changing seating plans 2019-11-28 12:12:12 +01:00
Raphael Michel
ee06844b0d Allow to sort the list of vouchers 2019-11-28 11:29:33 +01:00
Raphael Michel
d4a491fc1b Add new signal pretix.control.signals.order_search_filter_q 2019-11-28 11:18:09 +01:00
Raphael Michel
bb3348022f Allow to configure help text of email field 2019-11-28 11:11:55 +01:00
Raphael Michel
a10082a46f CSV exports: Subevent name and date in separate columns 2019-11-28 10:24:12 +01:00
Raphael Michel
ef555eaff1 Fix new tests on real databases 2019-11-27 17:40:42 +01:00
Raphael Michel
31adc37599 API: Add convenience methods to make it easier to create fees 2019-11-27 17:14:44 +01:00
Raphael Michel
1d02764be2 Do not show "other fee" text on invoices 2019-11-27 16:55:53 +01:00
Raphael Michel
f80f2e9bf9 Show "choose other event date" button even in iframes 2019-11-27 16:09:46 +01:00
Raphael Michel
f5e7e0e309 Fix crash if Bundle.designated_price is set to null 2019-11-27 16:07:22 +01:00
Raphael Michel
bbc70447a2 Allow to create vouchers for *all* products (#1504) 2019-11-27 14:57:09 +01:00
Raphael Michel
530632d624 Add invoice_line_text to documentation 2019-11-26 17:15:13 +01:00
Raphael Michel
370130f047 Add signal invoice_line_text 2019-11-26 16:41:41 +01:00
Raphael Michel
14575693b8 Add signal item_description 2019-11-26 16:36:25 +01:00
Raphael Michel
436dcc68f2 Show link for other dates in widget checkout 2019-11-25 16:44:42 +01:00
Raphael Michel
b49e02d988 Voucher redemption: Disable add-on-cart button if no products are
selected
2019-11-25 15:54:11 +01:00
Raphael Michel
037644421b Question statistics: Do not include canceled orders 2019-11-25 15:42:37 +01:00
Raphael Michel
654faa5a10 Order list: Filter by answers should be case-sensitive 2019-11-25 15:42:37 +01:00
Raphael Michel
53a22e0e88 Expiry warnings: Pre-fetch expiry column 2019-11-24 16:27:47 +01:00
Raphael Michel
f8a080d180 Refs #1289 -- Download reminders for subevents and download reminder performance 2019-11-24 16:05:03 +01:00
Raphael Michel
b013737d70 Fix AttributeError introduced in last commit 2019-11-24 15:40:14 +01:00
Raphael Michel
24d6816dac Improve performance of send_expire_warnings and expire_orders 2019-11-24 15:28:03 +01:00
Maico Timmerman
2eb92bef9b Send widget_data when opening waiting list (#1502) 2019-11-24 11:33:56 +01:00
Raphael Michel
8d5b4d190c Fix TypeError introduced in 50dabb5b2 2019-11-22 16:10:58 +01:00
Raphael Michel
50dabb5b26 Show seat in web and PDF check-in list 2019-11-22 13:32:39 +01:00
Raphael Michel
84fb25e4d9 Voucher creation: Auto-select products based on seat 2019-11-22 13:25:20 +01:00
Raphael Michel
ee4f75c2fb Introduce Seat.sorting_rank (#1499)
* Introduce Seat.sorting_rank

* Fix comments

* Comments, for real
2019-11-22 13:25:08 +01:00
Raphael Michel
1e63eb743c Merge pull request #1498 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-11-22 13:24:21 +01:00
Mie Frydensbjerg
09477e1431 Translated on translate.pretix.eu (Danish)
Currently translated at 48.1% (1600 of 3323 strings)

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

powered by weblate
2019-11-21 17:00:11 +00:00
Raphael Michel
2be49d8cab Bank transfer: Do not cause duplicate payments 2019-11-21 17:22:11 +01:00
Raphael Michel
808ccfee75 Bank import: useful matching of negative transactions
Negative transactions are never matched automatically, which does not
change. However, when matching them manually, a negative payment was
created, which does not make much sense. Now, if a negative payment is
manually matched, the system checks whether:

a) There is a manual refund in pending state. In this case, the refund
   will be marked as done.

b) There is a bank transfer payment of the same amount, in which case
   this is handled like a credit card chargeback (i.e. notification on
   the dashboard, ...)

c) Otherwise, an error will be returned asking the user to create a
   refund object manually.
2019-11-20 18:35:15 +01:00
Raphael Michel
485766e247 Sendmail plugins: Fix issues around history of checkin-filtered emails 2019-11-20 18:08:22 +01:00
Martin Gross
bab27f5ab6 Respect payment-saleschannel restrictions also on order change 2019-11-20 17:18:51 +01:00
Martin Gross
26141da1b3 Update from Weblate (#1497)
Currently translated at 70.9% (73 of 103 strings)

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

powered by weblate

Co-authored-by: Mie Frydensbjerg <mif@aarhus.dk>
2019-11-20 15:42:27 +01:00
Mie Frydensbjerg
4c6cf63d68 Translated on translate.pretix.eu (Danish)
Currently translated at 70.9% (73 of 103 strings)

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

powered by weblate
2019-11-20 14:41:46 +00:00
Martin Gross
24fa251b05 Update from Weblate (#1496)
Update from Weblate

Co-authored-by: Mie Frydensbjerg <mif@aarhus.dk>
2019-11-20 15:01:16 +01:00
Mie Frydensbjerg
d65eaf7b95 Translated on translate.pretix.eu (Danish)
Currently translated at 48.1% (1598 of 3323 strings)

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

powered by weblate
2019-11-20 13:59:25 +00:00
Mie Frydensbjerg
e24a7f3887 Translated on translate.pretix.eu (Danish)
Currently translated at 69.9% (72 of 103 strings)

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

powered by weblate
2019-11-20 13:54:08 +00:00
Martin Gross
ec66b20798 Fix ToC for docs 2019-11-20 12:55:19 +01:00
Martin Gross
2c3c9a4ba1 Merge pull request #1495 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-11-20 10:04:41 +01:00
Martin Gross
4c8e1cef7f Translated on translate.pretix.eu (Russian)
Currently translated at 33.8% (1123 of 3323 strings)

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

powered by weblate
2019-11-20 09:02:33 +00:00
Raphael Michel
180963c787 Widget: Auto-open seating plan in some situations 2019-11-19 22:48:47 +01:00
Raphael Michel
ee96fb2ca1 Fix widget test cases 2019-11-19 22:42:51 +01:00
Raphael Michel
5563259b11 Fix gettext import 2019-11-19 21:44:34 +01:00
Raphael Michel
e7b86e0deb Refactor attribution note in the widget 2019-11-19 21:16:04 +01:00
Raphael Michel
067c3b1abc Update from Weblate (#1494)
Update from Weblate
2019-11-19 17:00:48 +01:00
Maarten van den Berg
3fff04bcda Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3323 of 3323 strings)

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

powered by weblate
2019-11-19 15:55:59 +00:00
Maarten van den Berg
150ebc7ddb Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3323 of 3323 strings)

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

powered by weblate
2019-11-19 15:55:56 +00:00
Raphael Michel
2f906bae5c Translated on translate.pretix.eu (Latvian)
Currently translated at 33.7% (1120 of 3323 strings)

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

powered by weblate
2019-11-19 15:55:01 +00:00
Raphael Michel
82d1927fe7 Fix date range shown for one-day event 2019-11-19 16:16:08 +01:00
Raphael Michel
cde87b437a Update from Weblate (#1493)
Update from Weblate
2019-11-18 16:01:00 +01:00
Zane Smite
9cbf3490cf Translated on translate.pretix.eu (Latvian)
Currently translated at 33.7% (1120 of 3323 strings)

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

powered by weblate
2019-11-18 15:00:25 +00:00
Raphael Michel
56cfdfc5b3 Stripe: Only use alphanumeric characters in statement descriptors 2019-11-18 10:47:43 +01:00
Raphael Michel
8564d20183 Update from Weblate (#1492)
Update from Weblate
2019-11-18 10:46:11 +01:00
Raphael Michel
c3155ad16d Translated on translate.pretix.eu (Arabic)
Currently translated at 98.1% (101 of 103 strings)

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

powered by weblate
2019-11-18 09:44:59 +00:00
Raphael Michel
7fce062c2a Translated on translate.pretix.eu (Arabic)
Currently translated at 98.1% (101 of 103 strings)

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

powered by weblate
2019-11-18 09:41:05 +00:00
Raphael Michel
05311fda54 Update from Weblate (#1490)
Update from Weblate
2019-11-17 19:40:39 +01:00
Raphael Michel
0396c851ed Translated on translate.pretix.eu (Arabic)
Currently translated at 98.1% (101 of 103 strings)

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

powered by weblate
2019-11-17 18:35:20 +00:00
Zane Smite
1d2094f5ea Translated on translate.pretix.eu (Latvian)
Currently translated at 33.7% (1120 of 3323 strings)

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

powered by weblate
2019-11-17 18:35:20 +00:00
Maarten van den Berg
44b8531614 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 97.1% (3225 of 3323 strings)

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

powered by weblate
2019-11-17 18:35:20 +00:00
Raphael Michel
5e9610eecf Fix typo in function call 2019-11-17 19:35:06 +01:00
Raphael Michel
1c40351b27 Bulk voucher sending: Correctly strip lines of whitespace 2019-11-17 19:31:41 +01:00
Martin Gross
3a4196c087 Remove seating-icon filler to stop Chrome complaining about the CSP for inline-styling 2019-11-15 20:53:20 +01:00
Martin Gross
080b5f02cb Fix FAQ on linear barcodes spelling even more, ffs... 2019-11-15 19:21:30 +01:00
Martin Gross
21c052918c Fix FAQ on linear barcodes spelling 2019-11-15 18:53:58 +01:00
Raphael Michel
9515249098 Bulk voucher creation: Fix spaces in seat IDs 2019-11-15 18:44:47 +01:00
Martin Gross
a0dd495cc3 FAQ on linear barcodes 2019-11-15 16:27:07 +01:00
Martin Gross
46ce9904cd Fix documentation for override_layout signal 2019-11-15 15:54:50 +01:00
Martin Gross
600310da0f Fix override_layout signal 2019-11-15 12:12:13 +01:00
Martin Gross
3b306de1bb New signal: ticketoutput_override_layout (#1483)
* Add signal to override ticketoutput layout

* Change Signal collection to send_chained

* Move ticketoutput_override_layout to ticketoutputpdf-plugin
2019-11-15 11:18:52 +01:00
Raphael Michel
a2c1c69d7e Seat-specific vouchers (#1486)
* Basic functionality

* API

* Do not delete seats with vouchers

* Show seat in list of seats

* Validate availability of seats

* Fix invalid logic in Seat.is_available

* Show voucher name in edit form
2019-11-15 10:56:34 +01:00
Raphael Michel
f79df47b78 Add specific social media image 2019-11-15 10:33:55 +01:00
Raphael Michel
1703f3d636 Update from Weblate (#1489)
Update from Weblate
2019-11-15 09:47:48 +01:00
Raphael Michel
580ae7a2e9 Translated on translate.pretix.eu (Latvian)
Currently translated at 33.5% (1113 of 3323 strings)

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

powered by weblate
2019-11-15 08:42:30 +00:00
Raphael Michel
34703cf6de Translated on translate.pretix.eu (Russian)
Currently translated at 33.8% (1123 of 3323 strings)

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

powered by weblate
2019-11-15 08:32:53 +00:00
Raphael Michel
1999495638 Translated on translate.pretix.eu (Russian)
Currently translated at 33.8% (1123 of 3323 strings)

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

powered by weblate
2019-11-15 08:30:51 +00:00
Raphael Michel
00798d9207 Payment providers: Add priority flag 2019-11-15 09:29:38 +01:00
Raphael Michel
ac2ff6c2aa Phone number fields: Do not crash on invalid default value 2019-11-15 08:48:14 +01:00
Raphael Michel
35ac2f0d2b Merge pull request #1482 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-11-14 12:38:43 +01:00
Raphael Michel
3432bb089a Translated on translate.pretix.eu (Latvian)
Currently translated at 33.4% (1110 of 3323 strings)

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

powered by weblate
2019-11-14 11:37:41 +00:00
Zane Smite
69b03b24a1 Translated on translate.pretix.eu (Latvian)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-11-14 11:12:30 +00:00
Raphael Michel
520dd3e254 Refunds: Do not try to cancel canceled order 2019-11-14 12:12:15 +01:00
Raphael Michel
d98fce3594 Fix cancelling payments through the API 2019-11-14 12:10:45 +01:00
Raphael Michel
aa5ae8b2bd Do not crash because of invalid phone numbers in the database 2019-11-14 11:36:31 +01:00
Raphael Michel
b148182240 Cancel payments when cancelling order 2019-11-14 10:42:33 +01:00
Raphael Michel
339d7f06ed Payment provider API: Add method cancel_payment 2019-11-14 10:39:54 +01:00
Raphael Michel
b876293453 Fix problem when cancelling an order with invoices with different prefixes 2019-11-13 14:14:14 +01:00
Martin Gross
54091b9721 Add question type: phone number (#1462)
* Add Phonenumber-Field as to Questions

* Add setup requirements

* Add list of ask-during-checkin restricted question types and enforce it

* Fix requirements

* Fix crash using custom locales

* Re-format phone numbers when outputting to humans

* Initialize country code field with a guess for the customer's country

* Document TEL type in API docs
2019-11-13 12:52:07 +01:00
Raphael Michel
06a744ea2d Refactor cart management into new helper function cached_invoice_address 2019-11-13 11:42:03 +01:00
Raphael Michel
c24ab642ce Fix logic bug introduced in 0e9d2cfc1 during form validation 2019-11-13 10:30:29 +01:00
Raphael Michel
2ab3e1d420 Explicitly write Latvian with L 2019-11-13 10:08:51 +01:00
Raphael Michel
0e9d2cfc10 Require certain parts of names if names are required 2019-11-12 18:15:25 +01:00
Raphael Michel
63a46d0156 Fix russian translation 2019-11-12 17:28:49 +01:00
Martin Gross
6896682dd1 Restrict Payment Providers to Sales Channels (#1481)
* Allow to restrict payment providers to specific sales channels

* Fix test

* Add `payment_restrictions_supported`-property to SalesChannels
2019-11-12 17:11:43 +01:00
Raphael Michel
384e7f8fc1 Update from Weblate (#1479)
Update from Weblate
2019-11-12 17:11:29 +01:00
Mie Frydensbjerg
d403b3d433 Translated on translate.pretix.eu (Danish)
Currently translated at 48.0% (1595 of 3323 strings)

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

powered by weblate
2019-11-12 10:11:34 +00:00
Zane Smite
552e523229 Translated on translate.pretix.eu (Latvian)
Currently translated at 98.1% (101 of 103 strings)

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

powered by weblate
2019-11-12 10:11:34 +00:00
Maarten van den Berg
95ca6da74a Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3323 of 3323 strings)

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

powered by weblate
2019-11-12 10:11:34 +00:00
Zane Smite
bce8879804 Translated on translate.pretix.eu (Latvian)
Currently translated at 49.5% (51 of 103 strings)

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

powered by weblate
2019-11-12 10:11:34 +00:00
Raphael Michel
a05cce8af7 Translated on translate.pretix.eu (Russian)
Currently translated at 97.1% (100 of 103 strings)

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

powered by weblate
2019-11-12 10:11:34 +00:00
Zane Smite
dd6f1ee70f Translated on translate.pretix.eu (Latvian)
Currently translated at 10.7% (11 of 103 strings)

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

powered by weblate
2019-11-12 10:11:34 +00:00
Raphael Michel
ad91ed4a30 Correctly deal with tax rules that used to have custom rules 2019-11-12 11:11:21 +01:00
Raphael Michel
e3c3154469 Fix invoice duplication when importing payments for expired orders 2019-11-11 18:59:33 +01:00
Raphael Michel
54cd38bbaa Always render product names bold 2019-11-11 17:13:54 +01:00
Raphael Michel
8dbba1cfd3 Order details: Do not show download note when disabled on product level 2019-11-11 12:38:33 +01:00
Raphael Michel
0bf3ee1a6f Refactor into method in ClassicInvoiceRenderer 2019-11-10 13:07:06 +01:00
Raphael Michel
da7e1dee3e Offset refunds: Catch exceptions in the right place 2019-11-10 12:59:10 +01:00
Raphael Michel
df3cc1499f Fix #1480 -- Remove apparently obsolete code 2019-11-08 16:13:23 +01:00
Raphael Michel
2d066e3b5e Add CSS required for font preview to organizer settings 2019-11-08 09:19:27 +01:00
Raphael Michel
25cfa8973c API: Add invoice line position to invoice object 2019-11-07 15:43:22 +01:00
Raphael Michel
3f7f04ed21 Enable Russian and Latvian languages 2019-11-07 13:50:41 +01:00
Raphael Michel
88b2f4738a Ticket preview: use an existing subevent 2019-11-07 13:06:01 +01:00
Raphael Michel
ea71b1f04e Fix typo 2019-11-06 15:42:20 +01:00
Raphael Michel
03a06b6997 Fix wrong attribute name 2019-11-06 15:42:12 +01:00
Raphael Michel
1cd319357d Add "Invoice" to the filename of attached invoices 2019-11-06 14:10:42 +01:00
Raphael Michel
4c653aa4f1 Bump to 3.4.0.dev0 2019-11-06 14:05:54 +01:00
244 changed files with 68286 additions and 54706 deletions

View File

@@ -90,6 +90,11 @@ Example::
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
Defaults to ``off``.
``trust_x_forwarded_proto``
Specifies whether the ``X-Forwarded-Proto`` header can be trusted. Only set to ``on`` if you have a reverse
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
Defaults to ``off``.
Locale settings
---------------

View File

@@ -125,6 +125,8 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
; DO NOT change the following value, it has to be set to the location of the
; directory *inside* the docker container
datadir=/data
trust_x_forwarded_for=on
trust_x_forwarded_proto=on
[database]
; Replace postgresql with mysql for MySQL

View File

@@ -85,6 +85,8 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
url=https://pretix.mydomain.com
currency=EUR
datadir=/var/pretix/data
trust_x_forwarded_for=on
trust_x_forwarded_proto=on
[database]
; For MySQL, replace with "mysql"

View File

@@ -194,6 +194,7 @@ Cart position endpoints
* ``subevent`` (optional)
* ``expires`` (optional)
* ``includes_tax`` (optional)
* ``sales_channel`` (optional)
* ``answers``
* ``question``

View File

@@ -42,6 +42,7 @@ seating_plan integer If reserved sea
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
timezone string Event timezone name
===================================== ========================== =======================================================
@@ -74,6 +75,10 @@ seat_category_mapping object An object mappi
The attributes ``geo_lat`` and ``geo_lon`` have been added.
.. versionchanged:: 3.4
The attribute ``timezone`` has been added.
Endpoints
---------
@@ -127,6 +132,7 @@ Endpoints
"meta_data": {},
"seating_plan": null,
"seat_category_mapping": {},
"timezone": "Europe/Berlin",
"plugins": [
"pretix.plugins.banktransfer"
"pretix.plugins.stripe"
@@ -197,6 +203,7 @@ Endpoints
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"timezone": "Europe/Berlin",
"plugins": [
"pretix.plugins.banktransfer"
"pretix.plugins.stripe"
@@ -248,6 +255,7 @@ Endpoints
"geo_lon": null,
"has_subevents": false,
"meta_data": {},
"timezone": "Europe/Berlin",
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
@@ -281,6 +289,7 @@ Endpoints
"seat_category_mapping": {},
"has_subevents": false,
"meta_data": {},
"timezone": "Europe/Berlin",
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
@@ -334,6 +343,7 @@ Endpoints
"seat_category_mapping": {},
"has_subevents": false,
"meta_data": {},
"timezone": "Europe/Berlin",
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
@@ -367,6 +377,7 @@ Endpoints
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"timezone": "Europe/Berlin",
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
@@ -432,6 +443,7 @@ Endpoints
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"timezone": "Europe/Berlin",
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",

View File

@@ -221,9 +221,14 @@ Endpoints
"value": "15.37"
}
.. versionchanged:: 3.5
This endpoint now returns status code ``409`` if the transaction would lead to a negative gift card value.
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the gift card to modify
:statuscode 200: no error
:statuscode 400: The gift card 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.
:statuscode 409: There is not sufficient credit on the gift card.

View File

@@ -28,6 +28,7 @@ payment_provider_text string Text to be prin
payment information
footer_text string Text to be printed in the page footer area
lines list of objects The actual invoice contents
├ position integer Number of the line within an invoice.
├ description string Text representing the invoice line (e.g. product name)
├ gross_value money (string) Price including taxes
├ tax_value money (string) Tax amount included
@@ -63,6 +64,11 @@ internal_reference string Customer's refe
The attribute ``internal_reference`` has been added.
.. versionchanged:: 3.4
The attribute ``lines.number`` has been added.
Endpoints
---------
@@ -107,6 +113,7 @@ Endpoints
"footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321",
"lines": [
{
"position": 1,
"description": "Budget Ticket",
"gross_value": "23.00",
"tax_value": "0.00",
@@ -171,6 +178,7 @@ Endpoints
"footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321",
"lines": [
{
"position": 1,
"description": "Budget Ticket",
"gross_value": "23.00",
"tax_value": "0.00",

View File

@@ -61,9 +61,10 @@ invoice_address object Invoice address
└ vat_id_validated string ``true``, if the VAT ID has been validated against the
EU VAT service and validation was successful. This only
happens in rare cases.
positions list of objects List of non-canceled order positions (see below)
fees list of objects List of non-canceled fees included in the order total
(i.e. payment fees)
positions list of objects List of order positions (see below). By default, only
non-canceled positions are included.
fees list of objects List of fees included in the order total. By default, only
non-canceled fees are included.
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``other``)
├ value money (string) Fee amount
@@ -72,7 +73,8 @@ fees list of objects List of non-can
can be empty
├ tax_rate decimal (string) VAT rate applied for this fee
├ tax_value money (string) VAT included in this fee
tax_rule integer The ID of the used tax rule (or ``null``)
tax_rule integer The ID of the used tax rule (or ``null``)
└ canceled boolean Whether or not this fee has been canceled.
downloads list of objects List of ticket download options for order-wise ticket
downloading. This might be a multi-page PDF or a ZIP
file of tickets for outputs that do not support
@@ -145,6 +147,10 @@ last_modified datetime Last modificati
The ``invoice_address.state`` and ``url`` attributes have been added. When creating orders through the API,
vouchers are now supported and many fields are now optional.
.. versionchanged:: 3.5
The ``order.fees.canceled`` attribute has been added.
.. _order-position-resource:
@@ -159,6 +165,8 @@ Field Type Description
id integer Internal ID of the order position
order string Order code of the order the position belongs to
positionid integer Number of the position within the order
canceled boolean Whether or not this position has been canceled. Note that
by default, only non-canceled positions are shown.
item integer ID of the purchased item
variation integer ID of the purchased variation (or ``null``)
price money (string) Price of this position
@@ -224,6 +232,10 @@ pdf_data object Data object req
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
:ref:`order-position-ticket-download` for details.
.. versionchanged:: 3.5
The attribute ``canceled`` has been added.
.. _order-payment-resource:
Order payment resource
@@ -290,6 +302,10 @@ List of all orders
Filtering for emails or order codes is now case-insensitive.
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/
Returns a list of all orders within a given event.
@@ -355,6 +371,7 @@ List of all orders
"id": 23442,
"order": "ABC12",
"positionid": 1,
"canceled": false,
"item": 1345,
"variation": null,
"price": "23.00",
@@ -427,6 +444,9 @@ List of all orders
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
``require_approval`` will be returned.
:query include_canceled_positions: If set to ``true``, the output will contain canceled order positions. Note that this
only affects position-level cancellations, not fully-canceled orders.
:query include_canceled_fees: If set to ``true``, the output will contain canceled order fees.
:query string email: Only return orders created with the given email address
:query string locale: Only return orders with the given customer locale
:query datetime modified_since: Only return orders that have changed since the given date. Be careful: We only
@@ -444,6 +464,10 @@ List of all orders
Fetching individual orders
--------------------------
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
Returns information on one order, identified by its order code.
@@ -503,6 +527,7 @@ Fetching individual orders
"id": 23442,
"order": "ABC12",
"positionid": 1,
"canceled": false,
"item": 1345,
"variation": null,
"price": "23.00",
@@ -568,6 +593,9 @@ Fetching individual orders
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:query include_canceled_positions: If set to ``true``, the output will contain canceled order positions. Note that this
only affects position-level cancellations, not fully-canceled orders.
:query include_canceled_fees: If set to ``true``, the output will contain canceled order fees.
: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.
@@ -845,6 +873,14 @@ Creating orders
* ``description``
* ``internal_type``
* ``tax_rule``
* ``_treat_value_as_percentage`` (Optional convenience flag. If set to ``true``, your ``value`` parameter will
be treated as a percentage and the fee will be calculated using that percentage and the sum of all product
prices. Note that this will not include other fees and is calculated once during order generation and will not
be respected automatically when the order changes later.)
* ``_split_taxes_like_products`` (Optional convenience flag. If set to ``true``, your ``tax_rule`` will be ignored
and the fee will be taxed like the products in the order. If the products have multiple tax rates, multiple fees
will be generated with weights adjusted to the net price of the products. Note that this will be calculated once
during order generation and is not respected automatically when the order changes later.)
* ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order. Defaults to
@@ -1305,8 +1341,9 @@ List of all order positions
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
.. versionchanged:: 3.5
.. note:: Individually canceled order positions are currently not visible via the API at all.
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
@@ -1337,6 +1374,7 @@ List of all order positions
"id": 23442,
"order": "ABC12",
"positionid": 1,
"canceled": false,
"item": 1345,
"variation": null,
"price": "23.00",
@@ -1406,6 +1444,8 @@ List of all order positions
comma-separated IDs.
:query string voucher: Only return positions with a specific voucher.
:query string voucher__code: Only return positions with a specific voucher code.
:query include_canceled_positions: If set to ``true``, the output will contain canceled order positions. Note that this
only affects position-level cancellations, not fully-canceled orders.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
@@ -1439,6 +1479,7 @@ Fetching individual positions
"id": 23442,
"order": "ABC12",
"positionid": 1,
"canceled": false,
"item": 1345,
"variation": null,
"price": "23.00",
@@ -1483,6 +1524,7 @@ Fetching individual positions
: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 order position to fetch
:query include_canceled_positions: If set to ``true``, canceled positions may be returned (otherwise, they return 404).
: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.

View File

@@ -18,6 +18,7 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the question
question multi-lingual string The field label shown to the customer
help_text multi-lingual string The help text shown to the customer
type string The expected type of answer. Valid options:
* ``N`` number
@@ -31,6 +32,7 @@ type string The expected ty
* ``H`` time
* ``W`` date and time
* ``CC`` country code (ISO 3666-1 alpha-2)
* ``TEL`` telephone number
required boolean If ``true``, the question needs to be filled out.
position integer An integer, used for sorting
items list of integers List of item IDs this question is assigned to.
@@ -86,6 +88,10 @@ dependency_value string An old version
The attribute ``print_on_invoice`` has been added.
.. versionchanged:: 3.5
The attribute ``help_text`` has been added.
Endpoints
---------
@@ -122,6 +128,7 @@ Endpoints
{
"id": 1,
"question": {"en": "T-Shirt size"},
"help_text": {"en": "Choose your preferred t-shirt-size"},
"type": "C",
"required": false,
"items": [1, 2],
@@ -192,6 +199,7 @@ Endpoints
{
"id": 1,
"question": {"en": "T-Shirt size"},
"help_text": {"en": "Choose your preferred t-shirt-size"},
"type": "C",
"required": false,
"items": [1, 2],
@@ -247,6 +255,7 @@ Endpoints
{
"question": {"en": "T-Shirt size"},
"help_text": {"en": "Choose your preferred t-shirt-size"},
"type": "C",
"required": false,
"items": [1, 2],
@@ -281,6 +290,7 @@ Endpoints
{
"id": 1,
"question": {"en": "T-Shirt size"},
"help_text": {"en": "Choose your preferred t-shirt-size"},
"type": "C",
"required": false,
"items": [1, 2],
@@ -355,6 +365,7 @@ Endpoints
{
"id": 1,
"question": {"en": "T-Shirt size"},
"help_text": {"en": "Choose your preferred t-shirt-size"},
"type": "C",
"required": false,
"items": [1, 2],

View File

@@ -38,6 +38,7 @@ quota integer An ID of a quot
attached either to a specific product or to all
products within one quota or it can be available
for all items without restriction.
seat string ``seat_guid`` attribute of a specific seat (or ``null``)
tag string A string that is used for grouping vouchers
comment string An internal comment on the voucher
subevent integer ID of the date inside an event series this voucher belongs to (or ``null``).
@@ -53,6 +54,10 @@ show_hidden_items boolean Only if set to
The attribute ``show_hidden_items`` has been added.
.. versionchanged:: 3.4
The attribute ``seat`` has been added.
Endpoints
---------
@@ -96,7 +101,8 @@ Endpoints
"quota": null,
"tag": "testvoucher",
"comment": "",
"subevent": null
"seat": null,
"subevent": null,
}
]
}
@@ -162,6 +168,7 @@ Endpoints
"quota": null,
"tag": "testvoucher",
"comment": "",
"seat": null,
"subevent": null
}
@@ -225,6 +232,7 @@ Endpoints
"quota": null,
"tag": "testvoucher",
"comment": "",
"seat": null,
"subevent": null
}
@@ -352,6 +360,7 @@ Endpoints
"quota": null,
"tag": "testvoucher",
"comment": "",
"seat": null,
"subevent": null
}

12
doc/contents.rst Normal file
View File

@@ -0,0 +1,12 @@
Table of contents
=================
.. toctree::
:maxdepth: 3
user/index
admin/index
api/index
development/index
plugins/index

View File

@@ -20,13 +20,13 @@ Order events
There are multiple signals that will be sent out in the ordering cycle:
.. 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
: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
Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, 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
: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
.. automodule:: pretix.presale.signals
@@ -49,8 +49,8 @@ Backend
.. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms, item_formsets
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
item_formsets, order_search_filter_q
.. automodule:: pretix.base.signals
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
@@ -78,3 +78,6 @@ Ticket designs
.. automodule:: pretix.base.signals
:members: layout_text_variables
.. automodule:: pretix.plugins.ticketoutputpdf.signals
:members: override_layout

View File

@@ -0,0 +1,112 @@
.. highlight:: python
:linenothreshold: 5
.. _`importcol`:
Extending the order import process
==================================
It's possible through the backend to import orders into pretix, for example from a legacy ticketing system. If your
plugins defines additional data structures around orders, it might be useful to make it possible to import them as well.
Import process
--------------
Here's a short description of pretix' import process to show you where the system will need to interact with your plugin.
You can find more detailed descriptions of the attributes and methods further below.
1. The user uploads a CSV file. The system tries to parse the CSV file and understand its column headers.
2. A preview of the file is shown to the user and the user is asked to assign the various different input parameters to
columns of the file or static values. For example, the user either needs to manually select a product or specify a
column that contains a product. For this purpose, a select field is rendered for every possible input column,
allowing the user to choose between a default/empty value (defined by your ``default_value``/``default_label``)
attributes, the columns of the uploaded file, or a static value (defined by your ``static_choices`` method).
3. The user submits its assignment and the system uses the ``resolve`` method of all columns to get the raw value for
all columns.
4. The system uses the ``clean`` method of all columns to verify that all input fields are valid and transformed to the
correct data type.
5. The system prepares internal model objects (``Order`` etc) and uses the ``assign`` method of all columns to assign
these objects with actual values.
6. The system saves all of these model objects to the database in a database transaction. Plugins can create additional
objects in this stage through their ``save`` method.
Column registration
-------------------
The import API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available import columns. Your plugin
should listen for this signal and return the subclass of ``pretix.base.orderimport.ImportColumn``
that we'll provide in this plugin:
.. sourcecode:: python
from django.dispatch import receiver
from pretix.base.signals import order_import_columns
@receiver(order_import_columns, dispatch_uid="custom_columns")
def register_column(sender, **kwargs):
return [
EmailColumn(sender),
]
The column class API
--------------------
.. class:: pretix.base.orderimport.ImportColumn
The central object of each import extension is the subclass of ``ImportColumn``.
.. py:attribute:: ImportColumn.event
The default constructor sets this property to the event we are currently
working for.
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: verbose_name
This is an abstract attribute, you **must** override this!
.. autoattribute:: default_value
.. autoattribute:: default_label
.. autoattribute:: initial
.. automethod:: static_choices
.. automethod:: resolve
.. automethod:: clean
.. automethod:: assign
.. automethod:: save
Example
-------
For example, the import column responsible for assigning email addresses looks like this:
.. sourcecode:: python
class EmailColumn(ImportColumn):
identifier = 'email'
verbose_name = _('E-mail address')
def clean(self, value, previous_values):
if value:
EmailValidator()(value)
return value
def assign(self, value, order, position, invoice_address, **kwargs):
order.email = value

View File

@@ -15,6 +15,7 @@ Contents:
placeholder
invoice
shredder
import
customview
auth
general

View File

@@ -62,6 +62,8 @@ The provider class
.. autoattribute:: is_enabled
.. autoattribute:: priority
.. autoattribute:: settings_form_fields
.. automethod:: settings_form_clean
@@ -108,10 +110,14 @@ The provider class
.. automethod:: execute_refund
.. automethod:: refund_control_render
.. automethod:: api_payment_details
.. automethod:: shred_payment_info
.. automethod:: cancel_payment
.. autoattribute:: is_implicit
.. autoattribute:: is_meta

View File

@@ -1,12 +1 @@
Table of contents
=================
.. toctree::
:maxdepth: 3
user/index
admin/index
api/index
development/index
plugins/index
.. include:: contents.rst

View File

@@ -256,6 +256,11 @@ This works for the pretix Button as well. Currently, the following attributes ar
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
country code.
* If ``data-fix="true"`` is given, the user will not be able to change the other given values later. This currently
only works for the order email address as well as the invoice address. Attendee-level fields and questions can
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
on this for authentication.
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
Hosted or pretix Enterprise are active, you can pass the following fields:

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -30,11 +30,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
class Meta:
model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers', 'seat')
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel')
def create(self, validated_data):
answers_data = validated_data.pop('answers')
@@ -86,11 +87,12 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('The specified seat ID is not unique.')
else:
validated_data['seat'] = seat
if not seat.is_available():
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))
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
validated_data.pop('sales_channel')
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:

View File

@@ -4,7 +4,8 @@ from django.db import transaction
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from django_countries.serializers import CountryFieldMixin
from rest_framework.fields import Field
from pytz import common_timezones
from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -61,17 +62,27 @@ class PluginsField(Field):
}
class TimeZoneField(ChoiceField):
def get_attribute(self, instance):
return instance.cache.get_or_set(
'timezone_name',
lambda: instance.settings.timezone,
3600
)
class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones])
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
'plugins', 'seat_category_mapping')
'plugins', 'seat_category_mapping', 'timezone')
def validate(self, data):
data = super().validate(data)
@@ -156,8 +167,12 @@ class EventSerializer(I18nAwareModelSerializer):
meta_data = validated_data.pop('meta_data', None)
validated_data.pop('seat_category_mapping', None)
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
tz = validated_data.pop('timezone', None)
event = super().create(validated_data)
if tz:
event.settings.timezone = tz
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
@@ -182,8 +197,12 @@ class EventSerializer(I18nAwareModelSerializer):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
tz = validated_data.pop('timezone', None)
event = super().update(instance, validated_data)
if tz:
event.settings.timezone = tz
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in event.meta_values.select_related('property')}
@@ -240,6 +259,7 @@ class CloneEventSerializer(EventSerializer):
is_public = validated_data.pop('is_public', None)
testmode = validated_data.pop('testmode', None)
has_subevents = validated_data.pop('has_subevents', None)
tz = validated_data.pop('timezone', None)
new_event = super().create(validated_data)
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
@@ -254,6 +274,8 @@ class CloneEventSerializer(EventSerializer):
if has_subevents is not None:
new_event.has_subevents = has_subevents
new_event.save()
if tz:
new_event.settings.timezone = tz
return new_event

View File

@@ -122,9 +122,6 @@ class ItemSerializer(I18nAwareModelSerializer):
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard')
read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self):
return {"has_variations": self.kwargs['has_variations']}
def validate(self, data):
data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
@@ -230,7 +227,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice')
'hidden', 'dependency_value', 'print_on_invoice', 'help_text')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
@@ -270,6 +267,9 @@ class QuestionSerializer(I18nAwareModelSerializer):
seen_ids.add(dep.pk)
dep = dep.dependency_question
if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
Question.clean_items(event, full_data.get('items'))
return data

View File

@@ -1,5 +1,5 @@
import json
from collections import Counter
from collections import Counter, defaultdict
from decimal import Decimal
import pycountry
@@ -14,10 +14,11 @@ from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, Voucher,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -203,7 +204,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat')
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -283,7 +284,7 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderFee
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
class PaymentURLField(serializers.URLField):
@@ -477,9 +478,13 @@ class AnswerCreateSerializer(I18nAwareModelSerializer):
class OrderFeeCreateSerializer(I18nAwareModelSerializer):
_treat_value_as_percentage = serializers.BooleanField(default=False, required=False)
_split_taxes_like_products = serializers.BooleanField(default=False, required=False)
class Meta:
model = OrderFee
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule')
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule',
'_treat_value_as_percentage', '_split_taxes_like_products')
def validate_tax_rule(self, tr):
if tr and tr.event != self.context['event']:
@@ -715,6 +720,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
consume_carts = validated_data.pop('consume_carts', [])
delete_cps = []
quota_avail_cache = {}
v_budget = {}
voucher_usage = Counter()
if consume_carts:
for cp in CartPosition.objects.filter(
@@ -737,9 +743,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
errs = [{} for p in positions_data]
for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):
v = pos_data['voucher']
if pos_data.get('addon_to'):
errs[i]['voucher'] = ['Vouchers are currently not supported for add-on products.']
continue
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
continue
@@ -763,6 +774,44 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
'The voucher has already been used the maximum number of times.'
]
if v.budget is not None:
price = pos_data.get('price')
if price is None:
price = get_price(
item=pos_data.get('item'),
variation=pos_data.get('variation'),
voucher=v,
custom_price=None,
subevent=pos_data.get('subevent'),
addon_to=pos_data.get('addon_to'),
invoice_address=ia,
).gross
pbv = get_price(
item=pos_data['item'],
variation=pos_data.get('variation'),
voucher=None,
custom_price=None,
subevent=pos_data.get('subevent'),
addon_to=pos_data.get('addon_to'),
invoice_address=ia,
)
if v not in v_budget:
v_budget[v] = v.budget - v.budget_used()
disc = pbv.gross - price
if disc > v_budget[v]:
new_disc = v_budget[v]
v_budget[v] -= new_disc
if new_disc == Decimal('0.00') or pos_data.get('price') is not None:
errs[i]['voucher'] = [
'The voucher has a remaining budget of {}, therefore a discount of {} can not be '
'given.'.format(v_budget[v] + new_disc, disc)
]
continue
pos_data['price'] = price + (disc - new_disc)
else:
v_budget[v] -= disc
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if not seated:
@@ -773,7 +822,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
errs[i]['seat'] = ['The specified seat does not exist.']
else:
pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available()) 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)]
seats_seen.add(seat)
elif seated:
@@ -851,6 +900,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos.tax_rule = pos.item.tax_rule
else:
pos._calculate_tax()
pos.price_before_voucher = get_price(
item=pos.item,
variation=pos.variation,
voucher=None,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
).gross
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
pos.save()
@@ -863,13 +923,48 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
for cp in delete_cps:
cp.delete()
order.total = sum([p.price for p in order.positions.all()])
for fee_data in fees_data:
f = OrderFee(**fee_data)
f.order = order
f._calculate_tax()
f.save()
is_percentage = fee_data.pop('_treat_value_as_percentage', False)
if is_percentage:
fee_data['value'] = round_decimal(order.total * (fee_data['value'] / Decimal('100.00')),
self.context['event'].currency)
is_split_taxes = fee_data.pop('_split_taxes_like_products', False)
order.total = sum([p.price for p in order.positions.all()]) + sum([f.value for f in order.fees.all()])
if is_split_taxes:
d = defaultdict(lambda: Decimal('0.00'))
trz = TaxRule.zero()
for p in pos_map.values():
tr = p.tax_rule
d[tr] += p.price - p.tax_value
base_values = sorted([tuple(t) for t in d.items()], key=lambda t: (t[0] or trz).rate)
sum_base = sum(t[1] for t in base_values)
fee_values = [(t[0], round_decimal(fee_data['value'] * t[1] / sum_base, self.context['event'].currency))
for t in base_values]
sum_fee = sum(t[1] for t in fee_values)
# If there are rounding differences, we fix them up, but always leaning to the benefit of the tax
# authorities
if sum_fee > fee_data['value']:
fee_values[0] = (fee_values[0][0], fee_values[0][1] + (fee_data['value'] - sum_fee))
elif sum_fee < fee_data['value']:
fee_values[-1] = (fee_values[-1][0], fee_values[-1][1] + (fee_data['value'] - sum_fee))
for tr, val in fee_values:
fee_data['tax_rule'] = tr
fee_data['value'] = val
f = OrderFee(**fee_data)
f.order = order
f._calculate_tax()
f.save()
else:
f = OrderFee(**fee_data)
f.order = order
f._calculate_tax()
f.save()
order.total += sum([f.value for f in order.fees.all()])
order.save(update_fields=['total'])
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
@@ -905,10 +1000,25 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
return order
class LinePositionField(serializers.IntegerField):
"""
Internally, the position field is stored starting at 0, but for the API, starting at 1 makes it
more consistent with other models
"""
def to_representation(self, value):
return super().to_representation(value) + 1
def to_internal_value(self, data):
return super().to_internal_value(data) - 1
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
position = LinePositionField(read_only=True)
class Meta:
model = InvoiceLine
fields = ('description', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
fields = ('position', 'description', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
class InvoiceSerializer(I18nAwareModelSerializer):

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
@@ -26,7 +28,7 @@ class SeatingPlanSerializer(I18nAwareModelSerializer):
class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=10, decimal_places=2)
value = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.00'))
def validate(self, data):
data = super().validate(data)

View File

@@ -2,15 +2,23 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Voucher
from pretix.base.models import Seat, Voucher
class VoucherListSerializer(serializers.ListSerializer):
def create(self, validated_data):
codes = set()
seats = set()
errs = []
err = False
for voucher_data in validated_data:
if voucher_data.get('seat') and (voucher_data.get('seat'), voucher_data.get('subevent')) in seats:
err = True
errs.append({'code': ['Duplicate seat ID in request.']})
continue
else:
seats.add((voucher_data.get('seat'), voucher_data.get('subevent')))
if voucher_data['code'] in codes:
err = True
errs.append({'code': ['Duplicate voucher code in request.']})
@@ -22,12 +30,19 @@ class VoucherListSerializer(serializers.ListSerializer):
return super().create(validated_data)
class SeatGuidField(serializers.CharField):
def to_representation(self, val: Seat):
return val.seat_guid
class VoucherSerializer(I18nAwareModelSerializer):
seat = SeatGuidField(allow_null=True, required=False)
class Meta:
model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent', 'show_hidden_items')
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat')
read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer
@@ -39,7 +54,8 @@ class VoucherSerializer(I18nAwareModelSerializer):
Voucher.clean_item_properties(
full_data, self.context.get('event'),
full_data.get('quota'), full_data.get('item'), full_data.get('variation')
full_data.get('quota'), full_data.get('item'), full_data.get('variation'),
block_quota=full_data.get('block_quota')
)
Voucher.clean_subevent(
full_data, self.context.get('event')
@@ -61,4 +77,10 @@ class VoucherSerializer(I18nAwareModelSerializer):
)
Voucher.clean_voucher_code(full_data, self.context.get('event'), self.instance.pk if self.instance else None)
if full_data.get('seat'):
data['seat'] = Voucher.clean_seat_id(
full_data, full_data.get('item'), full_data.get('quota'), self.context.get('event'),
self.instance.pk if self.instance else None
)
return data

View File

@@ -133,6 +133,7 @@ class EventViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
serializer.save(organizer=self.request.organizer)
serializer.instance.set_defaults()
serializer.instance.log_action(
'pretix.event.added',
user=self.request.user,

View File

@@ -62,7 +62,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['has_variations'] = self.request.data.get('has_variations')
return ctx
def perform_update(self, serializer):

View File

@@ -30,8 +30,8 @@ from pretix.api.serializers.order import (
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
Order, OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
generate_position_secret, generate_secret,
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota,
TeamAPIToken, generate_position_secret, generate_secret,
)
from pretix.base.payment import PaymentException
from pretix.base.services import tickets
@@ -48,7 +48,7 @@ from pretix.base.services.orders import (
from pretix.base.services.pricing import get_price
from pretix.base.services.tickets import generate
from pretix.base.signals import (
order_modified, order_placed, register_ticket_outputs,
order_modified, order_paid, order_placed, register_ticket_outputs,
)
from pretix.base.templatetags.money import money_filter
@@ -82,20 +82,29 @@ class OrderViewSet(viewsets.ModelViewSet):
return ctx
def get_queryset(self):
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
fqs = OrderFee.all
else:
fqs = OrderFee.objects
qs = self.request.event.orders.prefetch_related(
'fees', 'payments', 'refunds', 'refunds__payment'
Prefetch('fees', queryset=fqs.all()),
'payments', 'refunds', 'refunds__payment'
).select_related(
'invoice_address'
)
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
opq = OrderPosition.all
else:
opq = OrderPosition.objects
if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related(
Prefetch(
'positions',
OrderPosition.objects.all().prefetch_related(
opq.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to', 'seat',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat'))
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
)
)
)
@@ -103,7 +112,7 @@ class OrderViewSet(viewsets.ModelViewSet):
qs = qs.prefetch_related(
Prefetch(
'positions',
OrderPosition.objects.all().prefetch_related(
opq.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
)
)
@@ -173,9 +182,26 @@ class OrderViewSet(viewsets.ModelViewSet):
amount=ps
)
except OrderPayment.DoesNotExist:
order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED)) \
.update(state=OrderPayment.PAYMENT_STATE_CANCELED)
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED)):
try:
with transaction.atomic():
p.payment_provider.cancel_payment(p)
order.log_action('pretix.event.order.payment.canceled', {
'local_id': p.local_id,
'provider': p.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
except PaymentException as e:
order.log_action(
'pretix.event.order.payment.canceled.failed',
{
'local_id': p.local_id,
'provider': p.provider,
'error': str(e)
},
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth
)
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='manual',
@@ -448,6 +474,8 @@ class OrderViewSet(viewsets.ModelViewSet):
with language(order.locale):
order_placed.send(self.request.event, order=order)
if order.status == Order.STATUS_PAID:
order_paid.send(self.request.event, order=order)
gen_invoice = invoice_qualified(order) and (
(order.event.settings.get('invoice_generate') == 'True') or
@@ -635,11 +663,16 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
}
def get_queryset(self):
qs = OrderPosition.objects.filter(order__event=self.request.event)
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
qs = OrderPosition.all
else:
qs = OrderPosition.objects
qs = qs.filter(order__event=self.request.event)
if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
Prefetch('addons', qs.select_related('item', 'variation')),
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
Prefetch(
'event',
@@ -647,7 +680,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
),
Prefetch(
'positions',
OrderPosition.objects.prefetch_related(
qs.prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
)
)
@@ -657,7 +690,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
)
else:
qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question'
'checkins', 'answers', 'answers__options', 'answers__question',
).select_related(
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
)
@@ -896,13 +929,16 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.save()
payment.order.log_action('pretix.event.order.payment.canceled', {
'local_id': payment.local_id,
'provider': payment.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
try:
with transaction.atomic():
payment.payment_provider.cancel_payment(payment)
payment.order.log_action('pretix.event.order.payment.canceled', {
'local_id': payment.local_id,
'provider': payment.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
except PaymentException as e:
return Response({'detail': 'External error: {}'.format(str(e))},
status=status.HTTP_400_BAD_REQUEST)
return self.retrieve(request, [], **kwargs)

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.db import transaction
from rest_framework import filters, serializers, status, viewsets
from rest_framework.decorators import action
@@ -136,6 +138,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
request.data.get('value')
)
if gc.value + value < Decimal('0.00'):
return Response({
'value': ['The gift card does not have sufficient credit for this operation.']
}, status=status.HTTP_409_CONFLICT)
gc.transactions.create(value=value)
gc.log_action(
'pretix.giftcards.transaction.manual',

View File

@@ -45,7 +45,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
write_permission = 'can_change_vouchers'
def get_queryset(self):
return self.request.event.vouchers.all()
return self.request.event.vouchers.select_related('seat').all()
def _predict_quota_check(self, data, instance):
# This method predicts if Voucher.clean_quota_needs_checking

View File

@@ -13,7 +13,7 @@ class PretixBaseConfig(AppConfig):
from . import invoice # NOQA
from . import notifications # NOQA
from . import email # NOQA
from .services import auth, checkin, export, mail, tickets, cart, 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
try:
from .celery_app import app as celery_app # NOQA

View File

@@ -42,6 +42,25 @@ class SalesChannel:
"""
return True
@property
def payment_restrictions_supported(self) -> bool:
"""
If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel.
Example: pretixPOS provides its own sales channel, ignores the configured payment providers completely and
handles payments locally. Therefor, this property should be set to ``False`` for the pretixPOS sales channel as
the event organizer cannot restrict the usage of any payment provider through the backend.
"""
return True
@property
def unlimited_items_per_order(self) -> bool:
"""
If this property is ``True``, purchases made using this sales channel are not limited to the maximum amount of
items defined in the event settings.
"""
return False
def get_all_sales_channels():
global _ALL_CHANNELS

View File

@@ -9,7 +9,7 @@ from django.core.mail.backends.smtp import EmailBackend
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import get_language, ugettext_lazy as _
from inlinestyler.utils import inline_css
from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber
@@ -112,7 +112,8 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
'site_url': settings.SITE_URL,
'body': body_md,
'subject': str(subject),
'color': '#8E44B3'
'color': '#8E44B3',
'rtl': get_language() in settings.LANGUAGES_RTL
}
if self.event:
htmlctx['event'] = self.event

View File

@@ -128,7 +128,7 @@ class ListExporter(BaseExporter):
def _render_xlsx(self, form_data, output_file=None):
wb = Workbook()
ws = wb.get_active_sheet()
ws = wb.active
try:
ws.title = str(self.verbose_name)
except:
@@ -207,7 +207,7 @@ class MultiSheetListExporter(ListExporter):
def _render_xlsx(self, form_data, output_file=None):
wb = Workbook()
ws = wb.get_active_sheet()
ws = wb.active
wb.remove(ws)
for s, l in self.sheets:
ws = wb.create_sheet(str(l))

View File

@@ -278,6 +278,8 @@ class OrderListExporter(MultiSheetListExporter):
]
if self.event.has_subevents:
headers.append(pgettext('subevent', 'Date'))
headers.append(_('Start date'))
headers.append(_('End date'))
headers += [
_('Product'),
_('Variation'),
@@ -323,7 +325,12 @@ class OrderListExporter(MultiSheetListExporter):
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
]
if self.event.has_subevents:
row.append(op.subevent)
row.append(op.subevent.name)
row.append(op.subevent.date_from.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
if op.subevent.date_to:
row.append(op.subevent.date_to.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
else:
row.append('')
row += [
str(op.item),
str(op.variation) if op.variation else '',

View File

@@ -32,7 +32,8 @@ class LoginForm(forms.Form):
for k, f in backend.login_form_fields.items():
self.fields[k] = f
if not settings.PRETIX_LONG_SESSIONS:
# Authentication backends which use urls cannot have long sessions.
if not settings.PRETIX_LONG_SESSIONS or backend.url:
del self.fields['keep_logged_in']
else:
self.fields.move_to_end('keep_logged_in')
@@ -197,6 +198,7 @@ class ReauthForm(forms.Form):
self.request = request
self.user = user
self.backend = backend
self.backend.url = backend.authentication_url(self.request)
super().__init__(*args, **kwargs)
for k, f in backend.login_form_fields.items():
self.fields[k] = f

View File

@@ -9,6 +9,7 @@ import pycountry
import pytz
import vat_moss.errors
import vat_moss.id
from babel import localedata
from django import forms
from django.contrib import messages
from django.core.exceptions import ValidationError
@@ -21,11 +22,17 @@ from django.utils.translation import (
)
from django_countries import countries
from django_countries.fields import Country, CountryField
from phonenumber_field.formfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber
from phonenumber_field.widgets import PhoneNumberPrefixWidget
from phonenumbers import NumberParseException
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
from pretix.base.forms.widgets import (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
TimePickerWidget, UploadedFileWidget,
)
from pretix.base.i18n import language
from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.settings import (
@@ -41,6 +48,9 @@ from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__)
REQUIRED_NAME_PARTS = ['given_name', 'family_name', 'full_name']
class NamePartsWidget(forms.MultiWidget):
widget = forms.TextInput
autofill_map = {
@@ -91,15 +101,21 @@ class NamePartsWidget(forms.MultiWidget):
except (IndexError, TypeError):
widget_value = None
if id_:
final_attrs = dict(
these_attrs = dict(
final_attrs,
id='%s_%s' % (id_, i),
title=self.scheme['fields'][i][1],
placeholder=self.scheme['fields'][i][1],
)
final_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
final_attrs['data-size'] = self.scheme['fields'][i][2]
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer))
if self.scheme['fields'][i][0] in REQUIRED_NAME_PARTS:
if self.field.required:
these_attrs['required'] = 'required'
these_attrs.pop('data-no-required-attr', None)
these_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
these_attrs['data-size'] = self.scheme['fields'][i][2]
else:
these_attrs = final_attrs
output.append(widget.render(name + '_%s' % i, widget_value, these_attrs, renderer=renderer))
return mark_safe(self.format_output(output))
def format_output(self, rendered_widgets) -> str:
@@ -159,13 +175,45 @@ class NamePartsFormField(forms.MultiValueField):
def clean(self, value) -> dict:
value = super().clean(value)
if self.one_required and (not value or not any(v for v in value)):
if self.one_required and (not value or not any(v for v in value.values())):
raise forms.ValidationError(self.error_messages['required'], code='required')
if self.one_required:
for k, v in value.items():
if k in REQUIRED_NAME_PARTS and not v:
raise forms.ValidationError(self.error_messages['required'], code='required')
if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
return value
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def render(self, name, value, attrs=None, renderer=None):
output = super().render(name, value, attrs, renderer)
return mark_safe(self.format_output(output))
def format_output(self, rendered_widgets) -> str:
return '<div class="nameparts-form-group">%s</div>' % ''.join(rendered_widgets)
def guess_country(event):
# Try to guess the initial country from either the country of the merchant
# or the locale. This will hopefully save at least some users some scrolling :)
locale = get_language()
country = event.settings.invoice_address_from_country
if not country:
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale in valid_countries:
country = Country(locale.upper())
return country
class BaseQuestionsForm(forms.Form):
"""
This form class is responsible for asking order-related questions. This includes
@@ -315,6 +363,32 @@ class BaseQuestionsForm(forms.Form):
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
elif q.type == Question.TYPE_PHONENUMBER:
babel_locale = 'en'
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
if localedata.exists(get_language()):
babel_locale = get_language()
elif localedata.exists(get_language()[:2]):
babel_locale = get_language()[:2]
with language(babel_locale):
default_country = guess_country(event)
default_prefix = None
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if str(default_country) in values:
default_prefix = prefix
try:
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
except NumberParseException:
initial = None
field = PhoneNumberField(
label=label, required=required,
help_text=help_text,
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field.question = q
if answers:
# Cache the answer object for later use
@@ -420,23 +494,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
kwargs.setdefault('initial', {})
if not kwargs.get('instance') or not kwargs['instance'].country:
# Try to guess the initial country from either the country of the merchant
# or the locale. This will hopefully save at least some users some scrolling :)
locale = get_language()
country = event.settings.invoice_address_from_country
if not country:
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale in valid_countries:
country = Country(locale.upper())
kwargs['initial']['country'] = country
kwargs['initial']['country'] = guess_country(self.event)
super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid:

View File

@@ -457,19 +457,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
id='normal')
]
def _get_story(self, doc):
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
story = [
NextPageTemplate('FirstPage'),
Paragraph(pgettext('invoice', 'Invoice')
if not self.invoice.is_cancellation
else pgettext('invoice', 'Cancellation'),
self.stylesheet['Heading1']),
Spacer(1, 5 * mm),
NextPageTemplate('OtherPages'),
]
def _get_intro(self):
story = []
if self.invoice.internal_reference:
story.append(Paragraph(
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
@@ -478,7 +467,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.invoice_to_vat_id:
story.append(Paragraph(
pgettext('invoice', 'Customer VAT ID') + ':<br />' +
pgettext('invoice', 'Customer VAT ID') + ': ' +
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
self.stylesheet['Normal']
))
@@ -494,6 +483,25 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
story.append(Spacer(1, 10 * mm))
return story
def _get_story(self, doc):
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
story = [
NextPageTemplate('FirstPage'),
Paragraph(
(
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
else pgettext('invoice', 'Invoice')
) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'),
self.stylesheet['Heading1']
),
Spacer(1, 5 * mm),
NextPageTemplate('OtherPages'),
]
story += self._get_intro()
taxvalue_map = defaultdict(Decimal)
grossvalue_map = defaultdict(Decimal)

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.2.4 on 2019-11-14 11:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0139_auto_20191019_1317'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='seat',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers', to='pretixbase.Seat'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.4 on 2019-11-22 11:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0140_voucher_seat'),
]
operations = [
migrations.AddField(
model_name='seat',
name='sorting_rank',
field=models.BigIntegerField(default=0),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 2.2.7 on 2019-12-15 15:22
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0141_seat_sorting_rank'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='price_before_voucher',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='orderposition',
name='price_before_voucher',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='voucher',
name='budget',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
]

View File

@@ -3,6 +3,7 @@ import uuid
from collections import OrderedDict
from datetime import datetime, time, timedelta
from operator import attrgetter
from urllib.parse import urljoin
import pytz
from django.conf import settings
@@ -11,7 +12,7 @@ from django.core.files.storage import default_storage
from django.core.mail import get_connection
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Exists, 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.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -26,6 +27,7 @@ from pretix.base.validators import EventSlugBanlistValidator
from pretix.helpers.database import GroupConcat
from pretix.helpers.daterange import daterange
from pretix.helpers.json import safe_string
from pretix.helpers.thumb import get_thumbnail
from ..settings import settings_hierarkey
from .organizer import Organizer, Team
@@ -145,10 +147,13 @@ class EventMixin:
"@context": "http://schema.org",
"@type": "Event", "location": {
"@type": "Place",
"address": str(self.location)
"address": str(self.location),
},
"name": str(self.name)
"name": str(self.name),
}
img = getattr(self, 'event', self).social_image
if img:
eventdict['image'] = img
if self.settings.show_times:
eventdict["startDate"] = self.date_from.isoformat()
@@ -358,10 +363,41 @@ class Event(EventMixin, LoggedModel):
def __str__(self):
return str(self.name)
def set_defaults(self):
"""
This will be called after event creation, but only if the event was not created by copying an existing one.
This way, we can use this to introduce new default settings to pretix that do not affect existing events.
"""
self.settings.invoice_renderer = 'modern1'
self.settings.invoice_include_expire_date = True
@property
def free_seats(self):
def social_image(self):
from pretix.multidomain.urlreverse import build_absolute_uri
img = None
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
og_file = self.settings.get('og_image', as_type=str, default='')[7:]
if og_file:
img = get_thumbnail(og_file, '1200').thumb.url
elif logo_file:
img = get_thumbnail(logo_file, '5000x120').thumb.url
if img:
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
def free_seats(self, ignore_voucher=None, sales_channel='web'):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
from .vouchers import Voucher
vqs = Voucher.objects.filter(
event=self,
seat_id=OuterRef('pk'),
redeemed__lt=F('max_usages'),
).filter(
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
)
if ignore_voucher:
vqs = vqs.exclude(pk=ignore_voucher.pk)
qs = self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event=self,
@@ -375,8 +411,14 @@ class Event(EventMixin, LoggedModel):
seat_id=OuterRef('pk'),
expires__gte=now()
)
),
has_voucher=Exists(
vqs
)
).filter(has_order=False, has_cart=False, blocked=False)
).filter(has_order=False, has_cart=False, has_voucher=False)
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel:
qs = qs.filter(blocked=False)
return qs
@property
def presale_has_ended(self):
@@ -480,6 +522,7 @@ class Event(EventMixin, LoggedModel):
self.is_public = other.is_public
self.testmode = other.testmode
self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
tax_map = {}
for t in other.tax_rules.all():
@@ -487,6 +530,7 @@ class Event(EventMixin, LoggedModel):
t.pk = None
t.event = self
t.save()
t.log_action('pretix.object.cloned')
category_map = {}
for c in ItemCategory.objects.filter(event=other):
@@ -494,6 +538,7 @@ class Event(EventMixin, LoggedModel):
c.pk = None
c.event = self
c.save()
c.log_action('pretix.object.cloned')
item_map = {}
variation_map = {}
@@ -509,6 +554,7 @@ class Event(EventMixin, LoggedModel):
if i.tax_rule_id:
i.tax_rule = tax_map[i.tax_rule_id]
i.save()
i.log_action('pretix.object.cloned')
for v in vars:
variation_map[v.pk] = v
v.pk = None
@@ -533,6 +579,7 @@ class Event(EventMixin, LoggedModel):
q.cached_availability_time = None
q.closed = False
q.save()
q.log_action('pretix.object.cloned')
for i in items:
if i.pk in item_map:
q.items.add(item_map[i.pk])
@@ -548,6 +595,7 @@ class Event(EventMixin, LoggedModel):
q.pk = None
q.event = self
q.save()
q.log_action('pretix.object.cloned')
for i in items:
q.items.add(item_map[i.pk])
@@ -565,6 +613,7 @@ class Event(EventMixin, LoggedModel):
cl.pk = None
cl.event = self
cl.save()
cl.log_action('pretix.object.cloned')
for i in items:
cl.limit_products.add(item_map[i.pk])
@@ -632,7 +681,9 @@ class Event(EventMixin, LoggedModel):
pp = p(self)
providers[pp.identifier] = pp
self._cached_payment_providers = OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
self._cached_payment_providers = OrderedDict(sorted(
providers.items(), key=lambda v: (-v[1].priority, str(v[1].verbose_name))
))
return self._cached_payment_providers
def get_html_mail_renderer(self):
@@ -965,10 +1016,20 @@ class SubEvent(EventMixin, LoggedModel):
def __str__(self):
return '{} - {}'.format(self.name, self.get_date_range_display())
@property
def free_seats(self):
def free_seats(self, ignore_voucher=None, sales_channel='web'):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
from .vouchers import Voucher
vqs = Voucher.objects.filter(
event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
redeemed__lt=F('max_usages'),
).filter(
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
)
if ignore_voucher:
vqs = vqs.exclude(pk=ignore_voucher.pk)
qs = self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event_id=self.event_id,
@@ -984,8 +1045,14 @@ class SubEvent(EventMixin, LoggedModel):
seat_id=OuterRef('pk'),
expires__gte=now()
)
),
has_voucher=Exists(
vqs
)
).filter(has_order=False, has_cart=False, blocked=False)
).filter(has_order=False, has_cart=False, has_voucher=False)
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel:
qs = qs.filter(blocked=False)
return qs
@cached_property
def settings(self):

View File

@@ -10,10 +10,10 @@ from pretix.base.banlist import banned
from pretix.base.models import LoggedModel
def gen_giftcard_secret():
def gen_giftcard_secret(length):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True:
code = get_random_string(length=settings.ENTROPY['giftcard_secret'], allowed_chars=charset)
code = get_random_string(length=length, allowed_chars=charset)
if not banned(code) and not GiftCard.objects.filter(secret=code).exists():
return code
@@ -48,7 +48,6 @@ class GiftCard(LoggedModel):
)
secret = models.CharField(
max_length=190,
default=gen_giftcard_secret,
db_index=True,
verbose_name=_('Gift card code'),
)
@@ -69,6 +68,12 @@ class GiftCard(LoggedModel):
def accepted_by(self, organizer):
return self.issuer == organizer or GiftCardAcceptance.objects.filter(issuer=self.issuer, collector=organizer).exists()
def save(self, *args, **kwargs):
if not self.secret:
self.secret = gen_giftcard_secret(self.issuer.settings.giftcard_length)
super().save(*args, **kwargs)
class Meta:
unique_together = (('secret', 'issuer'),)

View File

@@ -191,6 +191,9 @@ class Invoice(models.Model):
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
if self.is_cancellation:
self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix
if '%' in self.prefix:
self.prefix = self.date.strftime(self.prefix)
if not self.invoice_no:
if self.order.testmode:
self.prefix += 'TEST-'

View File

@@ -175,8 +175,6 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
q &= Q(pk=voucher.item_id)
elif voucher.quota_id:
q &= Q(quotas__in=[voucher.quota_id])
else:
return qs.none()
if not voucher or not voucher.show_hidden_items:
q &= Q(hide_without_voucher=False)
@@ -977,6 +975,7 @@ class Question(LoggedModel):
TYPE_TIME = "H"
TYPE_DATETIME = "W"
TYPE_COUNTRYCODE = "CC"
TYPE_PHONENUMBER = "TEL"
TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")),
@@ -989,8 +988,10 @@ class Question(LoggedModel):
(TYPE_TIME, _("Time")),
(TYPE_DATETIME, _("Date and time")),
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
(TYPE_PHONENUMBER, _("Phone number")),
)
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER]
event = models.ForeignKey(
Event,
@@ -1105,17 +1106,25 @@ class Question(LoggedModel):
if self.type == Question.TYPE_CHOICE:
try:
return self.options.get(pk=answer)
return self.options.get(Q(pk=answer) | Q(identifier=answer))
except:
raise ValidationError(_('Invalid option selected.'))
elif self.type == Question.TYPE_CHOICE_MULTIPLE:
try:
if isinstance(answer, str):
return list(self.options.filter(pk__in=answer.split(",")))
else:
return list(self.options.filter(pk__in=answer))
except:
if isinstance(answer, str):
l_ = list(self.options.filter(
Q(pk__in=[a for a in answer.split(",") if a.isdigit()]) |
Q(identifier__in=answer.split(","))
))
llen = len(answer.split(','))
else:
l_ = list(self.options.filter(
Q(pk__in=[a for a in answer if isinstance(a, int) or a.isdigit()]) |
Q(identifier__in=answer)
))
llen = len(answer)
if len(l_) != llen:
raise ValidationError(_('Invalid option selected.'))
return l_
elif self.type == Question.TYPE_BOOLEAN:
return answer in ('true', 'True', True)
elif self.type == Question.TYPE_NUMBER:

View File

@@ -30,6 +30,8 @@ from django_countries.fields import Country, CountryField
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
from phonenumber_field.phonenumber import PhoneNumber
from phonenumbers import NumberParseException
from pretix.base.banlist import banned
from pretix.base.decimal import round_decimal
@@ -685,10 +687,12 @@ class Order(LockModel, LoggedModel):
error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
}
now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation', 'seat')
positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher')
quota_cache = {}
v_budget = {}
try:
for i, op in enumerate(positions):
if op.seat:
@@ -697,6 +701,16 @@ class Order(LockModel, LoggedModel):
if force:
continue
if op.voucher and op.voucher.budget is not None and op.price_before_voucher is not None:
if op.voucher not in v_budget:
v_budget[op.voucher] = op.voucher.budget - op.voucher.budget_used()
disc = op.price_before_voucher - op.price
if disc > v_budget[op.voucher]:
raise Quota.QuotaExceededException(error_messages['voucher_budget'].format(
voucher=op.voucher.code
))
v_budget[op.voucher] -= disc
quotas = list(op.quotas)
if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
@@ -922,6 +936,11 @@ class QuestionAnswer(models.Model):
return self.answer
elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer:
return Country(self.answer).name or self.answer
elif self.question.type == Question.TYPE_PHONENUMBER and self.answer:
try:
return PhoneNumber.from_string(self.answer).as_international
except NumberParseException:
return self.answer
else:
return self.answer
@@ -984,6 +1003,9 @@ class AbstractPosition(models.Model):
verbose_name=_("Variation"),
on_delete=models.PROTECT
)
price_before_voucher = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
price = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Price")
@@ -1213,6 +1235,7 @@ class OrderPayment(models.Model):
"""
return self.order.event.get_payment_providers(cached=True).get(self.provider)
@transaction.atomic()
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
@@ -1281,7 +1304,7 @@ class OrderPayment(models.Model):
'provider': self.provider,
}, user=user, auth=auth)
if self.order.status == Order.STATUS_PAID:
if self.order.status in (Order.STATUS_PAID, Order.STATUS_CANCELED):
return
payment_sum = self.order.payments.filter(
@@ -2025,7 +2048,7 @@ class InvoiceAddress(models.Model):
internal_reference = models.TextField(
verbose_name=_('Internal reference'),
help_text=_('This reference will be printed on your invoice for your convenience.'),
blank=True
blank=True,
)
beneficiary = models.TextField(
verbose_name=_('Beneficiary'),
@@ -2045,6 +2068,13 @@ class InvoiceAddress(models.Model):
self.name_parts = {}
super().save(**kwargs)
@property
def is_empty(self):
return (
not self.name_cached and not self.company and not self.street and not self.zipcode and not self.city
and not self.internal_reference and not self.beneficiary
)
@property
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))

View File

@@ -5,6 +5,7 @@ import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.utils.deconstruct import deconstructible
from django.utils.timezone import now
from django.utils.translation import gettext, ugettext_lazy as _
@@ -39,7 +40,7 @@ class SeatingPlan(LoggedModel):
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
Category = namedtuple('Categrory', 'name')
RawSeat = namedtuple('Seat', 'name guid number row category zone')
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank')
def __str__(self):
return self.name
@@ -59,16 +60,36 @@ class SeatingPlan(LoggedModel):
]
def iter_all_seats(self):
for z in self.layout_data['zones']:
for r in z['rows']:
for s in r['seats']:
# This returns all seats in a plan and assignes each of them a rank. The rank is used for sorting lists of
# seats later. The rank does not say anything about the *quality* of a seat, and is only meant as a heuristic
# to make it easier for humas to process lists of seats. The current algorithm assumes that there are less
# than 10'000 zones, less than 10'000 rows in every zone and less than 10'000 seats in every row.
# Respectively, no row/seat numbers may be numeric with a value of 10'000 or more. The resulting ranks
# *will* have gaps. We chose this way over just sorting the seats and continuously enumerating them as an
# 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 ri, r in enumerate(z['rows']):
try:
row_rank = int(r['row_number'])
except ValueError:
row_rank = ri
for si, s in enumerate(r['seats']):
try:
seat_rank = int(s['seat_number'])
except ValueError:
seat_rank = si
rank = (
10000 * 10000 * zi + 10000 * row_rank + seat_rank
)
yield self.RawSeat(
number=s['seat_number'],
guid=s['seat_guid'],
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
row=r['row_number'],
zone=z['name'],
category=s['category']
category=s['category'],
sorting_rank=rank
)
@@ -97,6 +118,10 @@ class Seat(models.Model):
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)
blocked = models.BooleanField(default=False)
sorting_rank = models.BigIntegerField(default=0)
class Meta:
ordering = ['sorting_rank', 'seat_guid']
def __str__(self):
parts = []
@@ -110,15 +135,24 @@ class Seat(models.Model):
return self.name
return ', '.join(parts)
def is_available(self, ignore_cart=None, ignore_orderpos=None):
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web'):
from .orders import Order
if self.blocked:
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
return False
opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
opqs = self.orderposition_set.filter(
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],
canceled=False
)
cpqs = self.cartposition_set.filter(expires__gte=now())
if ignore_cart:
vqs = self.vouchers.filter(
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now())) &
Q(redeemed__lt=F('max_usages'))
)
if ignore_cart and ignore_cart is not True:
cpqs = cpqs.exclude(pk=ignore_cart.pk)
if ignore_orderpos:
opqs = opqs.exclude(pk=ignore_orderpos.pk)
return not opqs.exists() and not cpqs.exists()
if ignore_voucher_id:
vqs = vqs.exclude(pk=ignore_voucher_id)
return not opqs.exists() and (ignore_cart is True or not cpqs.exists()) and not vqs.exists()

View File

@@ -198,8 +198,14 @@ class TaxRule(LoggedModel):
rate=self.rate, name=self.name
)
@property
def _custom_rules(self):
if not self.custom_rules:
return []
return json.loads(self.custom_rules)
def get_matching_rule(self, invoice_address):
rules = json.loads(self.custom_rules)
rules = self._custom_rules
if invoice_address:
for r in rules:
if r['country'] == 'EU' and str(invoice_address.country) not in EU_COUNTRIES:
@@ -216,7 +222,7 @@ class TaxRule(LoggedModel):
return {'action': 'vat'}
def is_reverse_charge(self, invoice_address):
if self.custom_rules:
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule['action'] == 'reverse'
@@ -238,7 +244,7 @@ class TaxRule(LoggedModel):
return False
def tax_applicable(self, invoice_address):
if self.custom_rules:
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule.get('action', 'vat') == 'vat'

View File

@@ -4,20 +4,21 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Q
from django.db.models import F, OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.banlist import banned
from pretix.base.models import SeatCategoryMapping
from pretix.base.models import Seat, SeatCategoryMapping
from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Quota
from .orders import Order
from .orders import Order, OrderPosition
def _generate_random_code(prefix=None):
@@ -114,6 +115,13 @@ class Voucher(LoggedModel):
verbose_name=_("Redeemed"),
default=0
)
budget = models.DecimalField(
verbose_name=_("Maximum discount budget"),
help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. "
"If this is sum reached, the voucher can no longer be used."),
decimal_places=2, max_digits=10,
null=True, blank=True
)
valid_until = models.DateTimeField(
blank=True, null=True, db_index=True,
verbose_name=_("Valid until")
@@ -171,6 +179,12 @@ class Voucher(LoggedModel):
"If enabled, the voucher is valid for any product affected by this quota."
)
)
seat = models.ForeignKey(
Seat, related_name='vouchers',
null=True, blank=True,
on_delete=models.PROTECT,
verbose_name=_("Specific seat"),
)
tag = models.CharField(
max_length=255,
verbose_name=_("Tag"),
@@ -211,11 +225,12 @@ class Voucher(LoggedModel):
self.event,
self.quota,
self.item,
self.variation
self.variation,
seats_given=bool(self.seat)
)
@staticmethod
def clean_item_properties(data, event, quota, item, variation):
def clean_item_properties(data, event, quota, item, variation, block_quota=False, seats_given=False):
if quota:
if quota.event != event:
raise ValidationError(_('You cannot select a quota that belongs to a different event.'))
@@ -234,8 +249,12 @@ class Voucher(LoggedModel):
'Otherwise it might be unclear which quotas to block.'))
if item.category and item.category.is_addon:
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
else:
raise ValidationError(_('You need to specify either a quota or a product.'))
elif block_quota:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.'))
elif variation:
raise ValidationError(_('You cannot select a variation without having selected a product that provides '
'variations.'))
@staticmethod
def clean_max_usages(data, redeemed):
@@ -324,7 +343,8 @@ class Voucher(LoggedModel):
elif item and not item.has_variations:
avail = item.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent'))
else:
raise ValidationError(_('You need to specify either a quota or a product.'))
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.'))
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < cnt):
raise ValidationError(_('You cannot create a voucher that blocks quota as the selected product or '
@@ -335,6 +355,42 @@ class Voucher(LoggedModel):
if 'code' in data and Voucher.objects.filter(Q(code__iexact=data['code']) & Q(event=event) & ~Q(pk=pk)).exists():
raise ValidationError(_('A voucher with this code already exists.'))
@staticmethod
def clean_seat_id(data, item, quota, event, pk):
try:
if event.has_subevents:
if not data.get('subevent'):
raise ValidationError(_('You need to choose a date if you select a seat.'))
seat = event.seats.select_related('product').get(
seat_guid=data.get('seat'), subevent=data.get('subevent')
)
else:
seat = event.seats.select_related('product').get(
seat_guid=data.get('seat')
)
except Seat.DoesNotExist:
raise ValidationError(_('The specified seat ID "{id}" does not exist for this event.').format(
id=data.get('seat')))
if not seat.is_available(ignore_voucher_id=pk, ignore_cart=True):
raise ValidationError(_('The seat "{id}" is currently unavailable (blocked, already sold or a '
'different voucher).').format(
id=seat.seat_guid))
if quota:
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
if data.get('max_usages', 1) > 1:
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
if item and seat.product != item:
raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product))
if not seat.is_available(ignore_voucher_id=pk):
raise ValidationError(_('The seat "{id}" is already sold or currently blocked.').format(id=seat.seat_guid))
return seat
def save(self, *args, **kwargs):
self.code = self.code.upper()
super().save(*args, **kwargs)
@@ -367,7 +423,9 @@ class Voucher(LoggedModel):
return item.quotas.filter(pk=self.quota_id).exists()
if self.item_id and not self.variation_id:
return self.item_id == item.pk
return (self.item_id == item.pk) and (variation and self.variation_id == variation.pk)
if self.item_id:
return (self.item_id == item.pk) and (variation and self.variation_id == variation.pk)
return True
def is_active(self):
"""
@@ -380,7 +438,7 @@ class Voucher(LoggedModel):
return False
return True
def calculate_price(self, original_price: Decimal) -> Decimal:
def calculate_price(self, original_price: Decimal, max_discount: Decimal=None) -> Decimal:
"""
Returns how the price given in original_price would be modified if this
voucher is applied, i.e. replaced by a different price or reduced by a
@@ -398,7 +456,9 @@ class Voucher(LoggedModel):
p = original_price
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
if places < 2:
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
p = p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
if max_discount is not None:
p = max(p, original_price - max_discount)
return p
return original_price
@@ -410,7 +470,7 @@ class Voucher(LoggedModel):
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
def seating_available(self):
def seating_available(self, subevent):
kwargs = {}
if self.subevent:
kwargs['subevent'] = self.subevent
@@ -419,4 +479,27 @@ class Voucher(LoggedModel):
elif self.item_id:
return self.item.seat_category_mappings.filter(**kwargs).exists()
else:
return False
return bool(subevent.seating_plan) if subevent else self.event.seating_plan
@classmethod
def annotate_budget_used_orders(cls, qs):
opq = OrderPosition.objects.filter(
voucher_id=OuterRef('pk'),
price_before_voucher__isnull=False,
order__status__in=[
Order.STATUS_PAID,
Order.STATUS_PENDING
]
).order_by().values('voucher_id').annotate(s=Sum(F('price_before_voucher') - F('price'))).values('s')
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=10, decimal_places=2)), Decimal('0.00')))
def budget_used(self):
ops = OrderPosition.objects.filter(
voucher=self,
price_before_voucher__isnull=False,
order__status__in=[
Order.STATUS_PAID,
Order.STATUS_PENDING
]
).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00')
return ops

View File

@@ -0,0 +1,612 @@
import re
from decimal import Decimal, DecimalException
import pycountry
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.utils import formats
from django.utils.functional import cached_property
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
)
from django_countries import countries
from django_countries.fields import Country
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.questions import guess_country
from pretix.base.models import (
ItemVariation, OrderPosition, QuestionAnswer, QuestionOption, Seat,
)
from pretix.base.services.pricing import get_price
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES,
)
from pretix.base.signals import order_import_columns
class ImportColumn:
@property
def identifier(self):
"""
Unique, internal name of the column.
"""
raise NotImplementedError
@property
def verbose_name(self):
"""
Human-readable description of the column
"""
raise NotImplementedError
@property
def initial(self):
"""
Initial value for the form component
"""
return None
@property
def default_value(self):
"""
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
option.
"""
return 'empty'
@property
def default_label(self):
"""
Human-readable description of the default assignment of this column, defaults to "Keep empty".
"""
return gettext_lazy('Keep empty')
def __init__(self, event):
self.event = event
def static_choices(self):
"""
This will be called when rendering the form component and allows you to return a list of values that can be
selected by the user statically during import.
:return: list of 2-tuples of strings
"""
return []
def resolve(self, settings, record):
"""
This method will be called to get the raw value for this field, usually by either using a static value or
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
the default should be fine.
"""
k = settings.get(self.identifier, self.default_value)
if k == self.default_value:
return None
elif k.startswith('csv:'):
return record.get(k[4:], None) or None
elif k.startswith('static:'):
return k[7:]
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
def clean(self, value, previous_values):
"""
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
You do not need to include the column or row name or value in the error message as it will automatically be
included.
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
e.g. if the column is empty or does not exist in this row.
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
"""
return value
def assign(self, value, order, position, invoice_address, **kwargs):
"""
This will be called to perform the actual import. You are supposed to set attributes on the ``order``, ``position``,
or ``invoice_address`` objects based on the input ``value``. This is called *before* the actual database
transaction, so these three objects do not yet have a primary key. If you want to create related objects, you
need to place them into some sort of internal queue and persist them when ``save`` is called.
"""
pass
def save(self, order):
"""
This will be called to perform the actual import. This is called inside the actual database transaction and the
input object ``order`` has already been saved to the database.
"""
pass
class EmailColumn(ImportColumn):
identifier = 'email'
verbose_name = gettext_lazy('E-mail address')
def clean(self, value, previous_values):
if value:
EmailValidator()(value)
return value
def assign(self, value, order, position, invoice_address, **kwargs):
order.email = value
class SubeventColumn(ImportColumn):
identifier = 'subevent'
verbose_name = pgettext_lazy('subevents', 'Date')
default_value = None
@cached_property
def subevents(self):
return list(self.event.subevents.filter(active=True).order_by('date_from'))
def static_choices(self):
return [
(str(p.pk), str(p)) for p in self.subevents
]
def clean(self, value, previous_values):
if not value:
raise ValidationError(pgettext("subevent", "You need to select a date."))
matches = [
p for p in self.subevents
if str(p.pk) == value or any(
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
]
if len(matches) == 0:
raise ValidationError(pgettext("subevent", "No matching date was found."))
if len(matches) > 1:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
return matches[0]
def assign(self, value, order, position, invoice_address, **kwargs):
position.subevent = value
def i18n_flat(l):
if isinstance(l.data, dict):
return l.data.values()
return [l.data]
class ItemColumn(ImportColumn):
identifier = 'item'
verbose_name = gettext_lazy('Product')
default_value = None
@cached_property
def items(self):
return list(self.event.items.filter(active=True))
def static_choices(self):
return [
(str(p.pk), str(p)) for p in self.items
]
def clean(self, value, previous_values):
matches = [
p for p in self.items
if str(p.pk) == value or (p.internal_name and p.internal_name == value) or any(
(v and v == value) for v in i18n_flat(p.name))
]
if len(matches) == 0:
raise ValidationError(_("No matching product was found."))
if len(matches) > 1:
raise ValidationError(_("Multiple matching products were found."))
return matches[0]
def assign(self, value, order, position, invoice_address, **kwargs):
position.item = value
class Variation(ImportColumn):
identifier = 'variation'
verbose_name = gettext_lazy('Product variation')
@cached_property
def items(self):
return list(ItemVariation.objects.filter(
active=True, item__active=True, item__event=self.event
).select_related('item'))
def static_choices(self):
return [
(str(p.pk), '{} {}'.format(p.item, p.value)) for p in self.items
]
def clean(self, value, previous_values):
if value:
matches = [
p for p in self.items
if str(p.pk) == value or any((v and v == value) for v in i18n_flat(p.value)) and p.item_id == previous_values['item'].pk
]
if len(matches) == 0:
raise ValidationError(_("No matching variation was found."))
if len(matches) > 1:
raise ValidationError(_("Multiple matching variations were found."))
return matches[0]
elif previous_values['item'].variations.exists():
raise ValidationError(_("You need to select a variation for this product."))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
position.variation = value
class InvoiceAddressCompany(ImportColumn):
identifier = 'invoice_address_company'
@property
def verbose_name(self):
return _('Invoice address') + ': ' + _('Company')
def assign(self, value, order, position, invoice_address, **kwargs):
invoice_address.company = value or ''
invoice_address.is_business = bool(value)
class InvoiceAddressNamePart(ImportColumn):
def __init__(self, event, key, label):
self.key = key
self.label = label
super().__init__(event)
@property
def verbose_name(self):
return _('Invoice address') + ': ' + str(self.label)
@property
def identifier(self):
return 'invoice_address_name_{}'.format(self.key)
def assign(self, value, order, position, invoice_address, **kwargs):
invoice_address.name_parts[self.key] = value or ''
class InvoiceAddressStreet(ImportColumn):
identifier = 'invoice_address_street'
@property
def verbose_name(self):
return _('Invoice address') + ': ' + _('Address')
def assign(self, value, order, position, invoice_address, **kwargs):
invoice_address.address = value or ''
class InvoiceAddressZip(ImportColumn):
identifier = 'invoice_address_zipcode'
@property
def verbose_name(self):
return _('Invoice address') + ': ' + _('ZIP code')
def assign(self, value, order, position, invoice_address, **kwargs):
invoice_address.zipcode = value or ''
class InvoiceAddressCity(ImportColumn):
identifier = 'invoice_address_city'
@property
def verbose_name(self):
return _('Invoice address') + ': ' + _('City')
def assign(self, value, order, position, invoice_address, **kwargs):
invoice_address.city = value or ''
class InvoiceAddressCountry(ImportColumn):
identifier = 'invoice_address_country'
default_value = None
@property
def initial(self):
return 'static:' + str(guess_country(self.event))
@property
def verbose_name(self):
return _('Invoice 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):
invoice_address.country = value or ''
class InvoiceAddressState(ImportColumn):
identifier = 'invoice_address_state'
@property
def verbose_name(self):
return _('Invoice address') + ': ' + _('State')
def clean(self, value, previous_values):
if value:
if previous_values.get('invoice_address_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('invoice_address_country')]
match = [
s for s in pycountry.subdivisions.get(country_code=previous_values.get('invoice_address_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):
invoice_address.state = value or ''
class InvoiceAddressVATID(ImportColumn):
identifier = 'invoice_address_vat_id'
@property
def verbose_name(self):
return _('Invoice address') + ': ' + _('VAT ID')
def assign(self, value, order, position, invoice_address, **kwargs):
invoice_address.vat_id = value or ''
class InvoiceAddressReference(ImportColumn):
identifier = 'invoice_address_internal_reference'
@property
def verbose_name(self):
return _('Invoice address') + ': ' + _('Internal reference')
def assign(self, value, order, position, invoice_address, **kwargs):
invoice_address.internal_reference = value or ''
class AttendeeNamePart(ImportColumn):
def __init__(self, event, key, label):
self.key = key
self.label = label
super().__init__(event)
@property
def verbose_name(self):
return _('Attendee name') + ': ' + str(self.label)
@property
def identifier(self):
return 'attendee_name_{}'.format(self.key)
def assign(self, value, order, position, invoice_address, **kwargs):
position.attendee_name_parts[self.key] = value or ''
class AttendeeEmail(ImportColumn):
identifier = 'attendee_email'
verbose_name = gettext_lazy('Attendee e-mail address')
def clean(self, value, previous_values):
if value:
EmailValidator()(value)
return value
def assign(self, value, order, position, invoice_address, **kwargs):
position.attendee_email = value
class Price(ImportColumn):
identifier = 'price'
verbose_name = gettext_lazy('Price')
default_label = gettext_lazy('Calculate from product')
def clean(self, value, previous_values):
if value not in (None, ''):
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
try:
value = Decimal(value)
except (DecimalException, TypeError):
raise ValidationError(_('You entered an invalid number.'))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
if value is None:
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
invoice_address=invoice_address)
else:
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
invoice_address=invoice_address, custom_price=value, force_custom_price=True)
position.price = p.gross
position.tax_rule = position.item.tax_rule
position.tax_rate = p.rate
position.tax_value = p.tax
class Secret(ImportColumn):
identifier = 'secret'
verbose_name = gettext_lazy('Ticket code')
default_label = gettext_lazy('Generate automatically')
def __init__(self, *args):
self._cached = set()
super().__init__(*args)
def clean(self, value, previous_values):
if value and (value in self._cached or OrderPosition.all.filter(order__event=self.event, secret=value).exists()):
raise ValidationError(
_('You cannot assign a position secret that already exists.')
)
self._cached.add(value)
return value
def assign(self, value, order, position, invoice_address, **kwargs):
if value:
position.secret = value
class Locale(ImportColumn):
identifier = 'locale'
verbose_name = gettext_lazy('Order locale')
default_value = None
@property
def initial(self):
return 'static:' + self.event.settings.locale
def static_choices(self):
locale_names = dict(settings.LANGUAGES)
return [
(a, locale_names[a]) for a in self.event.settings.locales
]
def clean(self, value, previous_values):
if not value:
value = self.event.settings.locale
if value not in self.event.settings.locales:
raise ValidationError(_("Please enter a valid language code."))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
order.locale = value
class Saleschannel(ImportColumn):
identifier = 'sales_channel'
verbose_name = gettext_lazy('Sales channel')
def static_choices(self):
return [
(sc.identifier, sc.verbose_name) for sc in get_all_sales_channels().values()
]
def clean(self, value, previous_values):
if not value:
value = 'web'
if value not in get_all_sales_channels():
raise ValidationError(_("Please enter a valid sales channel."))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
order.sales_channel = value
class SeatColumn(ImportColumn):
identifier = 'seat'
verbose_name = gettext_lazy('Seat ID')
def __init__(self, *args):
self._cached = set()
super().__init__(*args)
def clean(self, value, previous_values):
if value:
try:
value = Seat.objects.get(
seat_guid=value,
subevent=previous_values.get('subevent')
)
except Seat.DoesNotExist:
raise ValidationError(_('No matching seat was found.'))
if not value.is_available() or value in self._cached:
raise ValidationError(
_('The seat you selected has already been taken. Please select a different seat.'))
self._cached.add(value)
elif previous_values['item'].seat_category_mappings.filter(subevent=previous_values.get('subevent')).exists():
raise ValidationError(_('You need to select a specific seat.'))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
position.seat = value
class Comment(ImportColumn):
identifier = 'comment'
verbose_name = gettext_lazy('Comment')
def assign(self, value, order, position, invoice_address, **kwargs):
order.comment = value or ''
class QuestionColumn(ImportColumn):
def __init__(self, event, q):
self.q = q
super().__init__(event)
@property
def verbose_name(self):
return _('Question') + ': ' + str(self.q.question)
@property
def identifier(self):
return 'question_{}'.format(self.q.pk)
def clean(self, value, previous_values):
if value:
return self.q.clean_answer(value)
def assign(self, value, order, position, invoice_address, **kwargs):
if value:
if not hasattr(order, '_answers'):
order._answers = []
if isinstance(value, QuestionOption):
a = QuestionAnswer(orderposition=position, question=self.q, answer=str(value))
a._options = [value]
order._answers.append(a)
elif isinstance(value, list):
a = QuestionAnswer(orderposition=position, question=self.q, answer=', '.join(str(v) for v in value))
a._options = value
order._answers.append(a)
else:
order._answers.append(QuestionAnswer(question=self.q, answer=str(value), orderposition=position))
def save(self, order):
for a in getattr(order, '_answers', []):
a.orderposition = a.orderposition # This is apparently required after save() again
a.save()
if hasattr(a, '_options'):
a.options.add(*a._options)
def get_all_columns(event):
default = []
if event.has_subevents:
default.append(SubeventColumn(event))
default += [
EmailColumn(event),
ItemColumn(event),
Variation(event),
InvoiceAddressCompany(event),
]
scheme = PERSON_NAME_SCHEMES.get(event.settings.name_scheme)
for n, l, w in scheme['fields']:
default.append(InvoiceAddressNamePart(event, n, l))
default += [
InvoiceAddressStreet(event),
InvoiceAddressZip(event),
InvoiceAddressCity(event),
InvoiceAddressCountry(event),
InvoiceAddressState(event),
InvoiceAddressVATID(event),
InvoiceAddressReference(event),
]
for n, l, w in scheme['fields']:
default.append(AttendeeNamePart(event, n, l))
default += [
AttendeeEmail(event),
Price(event),
Secret(event),
Locale(event),
Saleschannel(event),
SeatColumn(event),
Comment(event)
]
for q in event.questions.exclude(type='F'):
default.append(QuestionColumn(event, q))
for recv, resp in order_import_columns.send(sender=event):
default += resp
return default

View File

@@ -20,6 +20,7 @@ from django_countries import Countries
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import PlaceholderValidator
from pretix.base.models import (
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
@@ -62,12 +63,11 @@ class BasePaymentProvider:
def __str__(self):
return self.identifier
@property
def is_implicit(self) -> bool:
def is_implicit(self, request: HttpRequest) -> bool:
"""
Returns whether or whether not this payment provider is an "implicit" payment provider that will
*always* and unconditionally be used if is_allowed() returns True and does not require any input.
This is intended to be used by the FreePaymentProvider, which skips the payment choice page.
This is intended to be used by the FreeOrderProvider, which skips the payment choice page.
By default, this returns ``False``. Please do not set this if you don't know exactly what you are doing.
"""
return False
@@ -83,6 +83,14 @@ class BasePaymentProvider:
"""
return False
@property
def priority(self) -> int:
"""
Returns a priority that is used for sorting payment providers. Higher priority means higher up in the list.
Default to 100. Providers with same priority are sorted alphabetically.
"""
return 100
@property
def is_enabled(self) -> bool:
"""
@@ -278,8 +286,21 @@ class BasePaymentProvider:
required=False,
disabled=not self.event.settings.invoice_address_required
)),
('_restrict_to_sales_channels',
forms.MultipleChoiceField(
label=_('Restrict to specific sales channels'),
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
if c.payment_restrictions_supported
),
initial=['web'],
widget=forms.CheckboxSelectMultiple,
help_text=_(
'Only allow the usage of this payment provider in the following sales channels'),
))
])
d['_restricted_countries']._as_type = list
d['_restrict_to_sales_channels']._as_type = list
return d
def settings_form_clean(self, cleaned_data):
@@ -391,7 +412,7 @@ class BasePaymentProvider:
The default implementation checks for the _availability_date setting to be either unset or in the future
and for the _total_max and _total_min requirements to be met. It also checks the ``_restrict_countries``
setting.
and ``_restrict_to_sales_channels`` setting.
:param total: The total value without the payment method fee, after taxes.
@@ -432,6 +453,10 @@ class BasePaymentProvider:
if str(ia.country) not in restricted_countries:
return False
if hasattr(request, 'sales_channel') and request.sales_channel.identifier not in \
self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
return False
return timing and pricing
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
@@ -587,6 +612,9 @@ class BasePaymentProvider:
if str(ia.country) not in restricted_countries:
return False
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
return False
return self._is_still_available(order=order)
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
@@ -623,6 +651,19 @@ class BasePaymentProvider:
"""
return ''
def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str:
"""
Will be called if the *event administrator* views the details of a refund.
It should return HTML code containing information regarding the current refund
status and, if applicable, next steps.
The default implementation returns an empty string.
:param order: The order object
"""
return ''
def payment_refund_supported(self, payment: OrderPayment) -> bool:
"""
Will be called to check if the provider supports automatic refunding for this
@@ -637,6 +678,17 @@ class BasePaymentProvider:
"""
return False
def cancel_payment(self, payment: OrderPayment):
"""
Will be called to cancel a payment. The default implementation just sets the payment state to canceled,
but in some cases you might want to notify an external provider.
On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method).
On failure, you should raise a PaymentException.
"""
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.save()
def execute_refund(self, refund: OrderRefund):
"""
Will be called to execute an refund. Note that refunds have an amount property and can be partial.
@@ -765,8 +817,7 @@ class ManualPayment(BasePaymentProvider):
return _('In test mode, you can just manually mark this order as paid in the backend after it has been '
'created.')
@property
def is_implicit(self):
def is_implicit(self, request: HttpRequest):
return 'pretix.plugins.manualpayment' not in self.event.plugins
def is_allowed(self, request: HttpRequest, total: Decimal=None):
@@ -871,7 +922,10 @@ class OffsettingProvider(BasePaymentProvider):
provider='offsetting',
info=json.dumps({'orders': [refund.order.code]})
)
p.confirm(ignore_date=True)
try:
p.confirm(ignore_date=True)
except Quota.QuotaExceededException:
pass
@property
def settings_form_fields(self) -> dict:
@@ -895,6 +949,7 @@ class OffsettingProvider(BasePaymentProvider):
class GiftCardPayment(BasePaymentProvider):
identifier = "giftcard"
verbose_name = _("Gift card")
priority = 10
@property
def settings_form_fields(self):
@@ -923,6 +978,20 @@ class GiftCardPayment(BasePaymentProvider):
def checkout_confirm_render(self, request) -> str:
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({})
def refund_control_render(self, request, refund) -> str:
from .models import GiftCard
if 'gift_card' in refund.info_data:
gc = GiftCard.objects.get(pk=refund.info_data.get('gift_card'))
template = get_template('pretixcontrol/giftcards/payment.html')
ctx = {
'request': request,
'event': self.event,
'gc': gc,
}
return template.render(ctx)
def payment_control_render(self, request, payment) -> str:
from .models import GiftCard
@@ -1080,7 +1149,7 @@ class GiftCardPayment(BasePaymentProvider):
@transaction.atomic()
def execute_refund(self, refund: OrderRefund):
from .models import GiftCard
gc = GiftCard.objects.get(pk=refund.payment.info_data.get('gift_card'))
gc = GiftCard.objects.get(pk=refund.info_data.get('gift_card') or refund.payment.info_data.get('gift_card'))
trans = gc.transactions.create(
value=refund.amount,
order=refund.order,

View File

@@ -10,6 +10,8 @@ from functools import partial
from io import BytesIO
import bleach
from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.dispatch import receiver
@@ -32,6 +34,7 @@ from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from pretix.base.i18n import language
from pretix.base.invoice import ThumbnailingImageReader
from pretix.base.models import Order, OrderPosition
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -198,6 +201,11 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Sample company"),
"evaluate": lambda op, order, ev: escape(order.invoice_address.company if getattr(order, 'invoice_address', None) else '')
}),
("invoice_city", {
"label": _("Invoice address city"),
"editor_sample": _("Sample city"),
"evaluate": lambda op, order, ev: escape(order.invoice_address.city if getattr(order, 'invoice_address', None) else '')
}),
("addons", {
"label": _("List of Add-Ons"),
"editor_sample": _("Addon 1\nAddon 2"),
@@ -405,7 +413,11 @@ class Renderer:
def _get_ev(self, op, order):
return op.subevent or order.event
def _get_text_content(self, op: OrderPosition, order: Order, o: dict):
def _get_text_content(self, op: OrderPosition, order: Order, o: dict, inner=False):
if o.get('locale', None) and not inner:
with language(o['locale']):
return self._get_text_content(op, order, o, True)
ev = self._get_ev(op, order)
if not o['content']:
return '(error)'
@@ -449,11 +461,32 @@ class Renderer:
tags=["br"], attributes={}, styles=[], strip=True
)
)
# 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.
configuration = {
'delete_harakat': True,
'support_ligatures': False,
}
reshaper = ArabicReshaper(configuration=configuration)
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
p = Paragraph(text, style=style)
p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
ad = getAscentDescent(font, float(o['fontsize']))
p.drawOn(canvas, float(o['left']) * mm, float(o['bottom']) * mm - ad[1])
canvas.saveState()
# The ascent/descent offsets here are not really proven to be correct, they're just empirical values to get
# reportlab render similarly to browser canvas.
if o.get('downward', False):
canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm)
canvas.rotate(o.get('rotation', 0) * -1)
p.drawOn(canvas, 0, -h - ad[1] / 2)
else:
canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm + h)
canvas.rotate(o.get('rotation', 0) * -1)
p.drawOn(canvas, 0, -h - ad[1])
canvas.restoreState()
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition):
for o in self.layout:

View File

@@ -1,3 +1,4 @@
import os
import sys
from enum import Enum
from typing import List
@@ -44,13 +45,14 @@ def get_all_plugins(event=None) -> List[type]:
class PluginConfig(AppConfig):
IGNORE = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not hasattr(self, 'PretixPluginMeta'):
raise ImproperlyConfigured("A pretix plugin config should have a PretixPluginMeta inner class.")
if hasattr(self.PretixPluginMeta, 'compatibility'):
if hasattr(self.PretixPluginMeta, 'compatibility') and not os.environ.get("PRETIX_IGNORE_CONFLICTS") == "True":
import pkg_resources
try:
pkg_resources.require(self.PretixPluginMeta.compatibility)

View File

@@ -103,11 +103,18 @@ class RelativeDateWrapper:
else:
timeparts = parts[2].split(':')
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
data = RelativeDate(
days_before=int(parts[1] or 0),
base_date_name=parts[3],
time=time
)
try:
data = RelativeDate(
days_before=int(parts[1] or 0),
base_date_name=parts[3],
time=time
)
except ValueError:
data = RelativeDate(
days_before=0,
base_date_name=parts[3],
time=time
)
else:
data = parser.parse(input)
return RelativeDateWrapper(data)

View File

@@ -12,9 +12,10 @@ from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext as _
from django_scopes import scopes_disabled
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat,
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat,
SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
@@ -80,6 +81,10 @@ error_messages = {
'cart if you want to use it for a different product.'),
'voucher_expired': _('This voucher is expired.'),
'voucher_invalid_item': _('This voucher is not valid for this product.'),
'voucher_invalid_seat': _('This voucher is not valid for this seat.'),
'voucher_no_match': _('We did not find any position in your cart that we could use this voucher for. If you want '
'to add something new to your cart using that voucher, you can do so with the voucher '
'redemption option on the bottom of the page.'),
'voucher_item_not_available': _(
'Your voucher is valid for a product that is currently not for sale.'),
'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'),
@@ -103,12 +108,15 @@ error_messages = {
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat',
'price_before_voucher'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price'))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent', 'seat'))
'quotas', 'subevent', 'seat', 'price_before_voucher'))
order = {
RemoveOperation: 10,
VoucherOperation: 15,
ExtendOperation: 20,
AddOperation: 30
}
@@ -217,15 +225,16 @@ class CartManager:
})
def _check_max_cart_size(self):
cartsize = self.positions.filter(addon_to__isnull=True).count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
not op.position.addon_to_id])
if cartsize > int(self.event.settings.max_items_per_order):
# TODO: i18n plurals
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
if not get_all_sales_channels()[self._sales_channel].unlimited_items_per_order:
cartsize = self.positions.filter(addon_to__isnull=True).count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
not op.position.addon_to_id])
if cartsize > int(self.event.settings.max_items_per_order):
# TODO: i18n plurals
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
def _check_item_constraints(self, op):
def _check_item_constraints(self, op, current_ops=[]):
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
if not (
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
@@ -252,6 +261,9 @@ class CartManager:
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
raise CartError(error_messages['voucher_invalid_item'])
if op.voucher and op.voucher.seat and op.voucher.seat != op.seat:
raise CartError(error_messages['voucher_invalid_seat'])
if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk:
raise CartError(error_messages['voucher_invalid_subevent'])
@@ -265,7 +277,7 @@ class CartManager:
raise CartError(error_messages['ended'])
seated = self._is_seated(op.item, op.subevent)
if seated and (not op.seat or op.seat.blocked):
if seated and (not op.seat or (op.seat.blocked and self._sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel)):
raise CartError(error_messages['seat_invalid'])
elif op.seat and not seated:
raise CartError(error_messages['seat_forbidden'])
@@ -294,10 +306,10 @@ class CartManager:
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
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
len([1 for _op in self._operations + current_ops
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
)
@@ -358,10 +370,10 @@ class CartManager:
cp.item.requires_seat = cp.requires_seat
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
if bundle:
price = bundle.designated_price or 0
except ItemBundle.DoesNotExist:
else:
price = cp.price
changed_prices[cp.pk] = price
@@ -373,6 +385,7 @@ class CartManager:
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True)
pbv = TAXED_ZERO
else:
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
@@ -385,9 +398,14 @@ class CartManager:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
bundled_sum=bundled_sum)
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
bundled_sum=bundled_sum)
quotas = list(cp.quotas)
if not quotas:
@@ -403,7 +421,7 @@ class CartManager:
op = self.ExtendOperation(
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat, price_before_voucher=pbv
)
self._check_item_constraints(op)
@@ -413,6 +431,60 @@ class CartManager:
self._operations.append(op)
return err
def apply_voucher(self, voucher_code: str):
if self._operations:
raise CartError('Applying a voucher to the whole cart should not be combined with other operations.')
try:
voucher = self.event.vouchers.get(code__iexact=voucher_code.strip())
except Voucher.DoesNotExist:
raise CartError(error_messages['voucher_invalid'])
voucher_use_diff = Counter()
ops = []
if not voucher.is_active():
raise CartError(error_messages['voucher_expired'])
for p in self.positions:
if p.voucher_id:
continue
if not voucher.applies_to(p.item, p.variation):
continue
if voucher.seat and voucher.seat != p.seat:
continue
if voucher.subevent_id and voucher.subevent_id != p.subevent_id:
continue
if p.is_bundled:
continue
bundled_sum = Decimal('0.00')
if not p.addon_to_id:
for bundledp in p.addons.all():
if bundledp.is_bundled:
bundledprice = bundledp.price
bundled_sum += bundledprice
price = self._get_price(p.item, p.variation, voucher, None, p.subevent, bundled_sum=bundled_sum)
"""
if price.gross > p.price:
continue
"""
voucher_use_diff[voucher] += 1
ops.append((p.price - price.gross, self.VoucherOperation(p, voucher, price)))
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
# the user the most.
ops.sort(key=lambda k: k[0], reverse=True)
self._operations += [k[1] for k in ops]\
if not voucher_use_diff:
raise CartError(error_messages['voucher_no_match'])
self._voucher_use_diff += voucher_use_diff
def add_new_items(self, items: List[dict]):
# Fetch items from the database
self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items])
@@ -423,7 +495,7 @@ class CartManager:
for i in items:
if self.event.has_subevents:
if not i.get('subevent'):
if not i.get('subevent') or int(i.get('subevent')) not in self._subevents_cache:
raise CartError(error_messages['subevent_required'])
subevent = self._subevents_cache[int(i.get('subevent'))]
else:
@@ -504,18 +576,20 @@ class CartManager:
bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[], seat=None
includes_tax=bool(bprice.rate), bundled=[], seat=None, price_before_voucher=bprice,
)
self._check_item_constraints(bop)
self._check_item_constraints(bop, operations)
bundled.append(bop)
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
pbv = self._get_price(item, variation, None, i.get('price'), subevent, bundled_sum=bundled_sum)
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat,
price_before_voucher=pbv
)
self._check_item_constraints(op)
self._check_item_constraints(op, operations)
operations.append(op)
self._quota_diff.update(quota_diff)
@@ -619,9 +693,10 @@ class CartManager:
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
price_before_voucher=None
)
self._check_item_constraints(op)
self._check_item_constraints(op, operations)
operations.append(op)
# Check constraints on the add-on combinations
@@ -756,7 +831,7 @@ class CartManager:
self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set()
for op in self._operations:
for iop, op in enumerate(self._operations):
if isinstance(op, self.RemoveOperation):
if op.position.expires > self.now_dt:
for q in op.position.quotas:
@@ -824,7 +899,7 @@ class CartManager:
available_count = 0
if isinstance(op, self.AddOperation):
if op.seat and not op.seat.is_available():
if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None, sales_channel=self._sales_channel):
available_count = 0
err = err or error_messages['seat_unavailable']
@@ -833,7 +908,8 @@ class CartManager:
event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat,
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
)
if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -872,13 +948,16 @@ class CartManager:
new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation):
if op.seat and not op.seat.is_available(ignore_cart=op.position):
if op.seat and not op.seat.is_available(ignore_cart=op.position, sales_channel=self._sales_channel,
ignore_voucher_id=op.position.voucher_id):
err = err or error_messages['seat_unavailable']
op.position.addons.all().delete()
op.position.delete()
elif available_count == 1:
op.position.expires = self._expiry
op.position.price = op.price.gross
if op.price_before_voucher is not None:
op.position.price_before_voucher = op.price_before_voucher.gross
try:
op.position.save(force_update=True)
except DatabaseError:
@@ -889,6 +968,20 @@ class CartManager:
op.position.delete()
else:
raise AssertionError("ExtendOperation cannot affect more than one item")
elif isinstance(op, self.VoucherOperation):
if vouchers_ok[op.voucher] < 1:
if iop == 0:
raise CartError(error_messages['voucher_redeemed'])
else:
# We fail silently if we could only apply the voucher to part of the cart, since that might
# be expected
continue
op.position.price_before_voucher = op.position.price
op.position.price = op.price.gross
op.position.voucher = op.voucher
op.position.save()
vouchers_ok[op.voucher] -= 1
for p in new_cart_positions:
if getattr(p, '_answers', None):
@@ -1054,7 +1147,27 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en') -> None:
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param voucher: A voucher code
:param session: Session ID of a guest
"""
with language(locale):
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.apply_voucher(voucher)
cm.commit()
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy'])
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
@@ -1064,7 +1177,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
with language(locale):
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.remove_item(position)
cm.commit()
except LockTimeoutException:
@@ -1074,7 +1187,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
@@ -1083,7 +1196,7 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
with language(locale):
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.clear()
cm.commit()
except LockTimeoutException:

View File

@@ -13,6 +13,7 @@ from django.db import transaction
from django.db.models import Count
from django.dispatch import receiver
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _
from django_countries.fields import Country
@@ -20,11 +21,13 @@ from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
from pretix.base.models import (
Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
)
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import periodic_task
from pretix.base.signals import invoice_line_text, periodic_task
from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction
from pretix.helpers.models import modelcopy
@@ -50,11 +53,17 @@ def build_invoice(invoice: Invoice) -> Invoice:
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if lp and lp.payment_provider:
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
payment = lp.payment_provider.render_invoice_text(invoice.order, lp)
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
else:
payment = lp.payment_provider.render_invoice_text(invoice.order)
payment = str(lp.payment_provider.render_invoice_text(invoice.order))
else:
payment = ""
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
if payment:
payment += "<br />"
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
)
invoice.introductory_text = str(introductory).replace('\n', '<br />')
invoice.additional_text = str(additional).replace('\n', '<br />')
@@ -139,6 +148,9 @@ def build_invoice(invoice: Invoice) -> Invoice:
desc = " + " + desc
if invoice.event.settings.invoice_attendee_name and p.attendee_name:
desc += "<br />" + pgettext("invoice", "Attendee: {name}").format(name=p.attendee_name)
for recv, resp in invoice_line_text.send(sender=invoice.event, position=p):
if resp:
desc += "<br/>" + resp
for answ in p.answers.all():
if not answ.question.print_on_invoice:
@@ -174,9 +186,12 @@ def build_invoice(invoice: Invoice) -> Invoice:
offset = len(positions)
for i, fee in enumerate(invoice.order.fees.all()):
fee_title = _(fee.get_fee_type_display())
if fee.description:
fee_title += " - " + fee.description
if fee.fee_type == OrderFee.FEE_TYPE_OTHER and fee.description:
fee_title = fee.description
else:
fee_title = _(fee.get_fee_type_display())
if fee.description:
fee_title += " - " + fee.description
InvoiceLine.objects.create(
position=i + offset,
invoice=invoice,
@@ -203,7 +218,7 @@ def build_cancellation(invoice: Invoice):
def generate_cancellation(invoice: Invoice, trigger_pdf=True):
if invoice.refered.exists():
if invoice.canceled:
raise ValueError("Invoice should not be canceled twice.")
cancellation = modelcopy(invoice)
cancellation.pk = None

View File

@@ -3,6 +3,7 @@ import logging
import os
import re
import smtplib
import ssl
import warnings
from email.mime.image import MIMEImage
from email.utils import formataddr
@@ -14,9 +15,12 @@ import requests
from bs4 import BeautifulSoup
from celery import chain
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.core.mail import (
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
)
from django.core.mail.message import SafeMIMEText
from django.template.loader import get_template
from django.utils.translation import ugettext as _
from django.utils.translation import pgettext, ugettext as _
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
@@ -129,7 +133,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
else:
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
subject = str(subject)
subject = raw_subject = str(subject)
signature = ""
bcc = []
@@ -195,13 +199,13 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
try:
if 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, str(subject), order, position)
body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else:
# Backwards compatibility
warnings.warn('E-mail renderer called without position argument because position argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, str(subject), order)
body_html = renderer.render(content_plain, signature, raw_subject, order)
except:
logger.exception('Could not render HTML body')
body_html = None
@@ -232,16 +236,32 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
chain(*task_chain).apply_async()
class CustomEmail(EmailMultiAlternatives):
def _create_mime_attachment(self, content, mimetype):
"""
Convert the content, mimetype pair into a MIME attachment object.
If the mimetype is message/rfc822, content may be an
email.Message or EmailMessage object, as well as a str.
"""
basetype, subtype = mimetype.split('/', 1)
if basetype == 'multipart' and isinstance(content, SafeMIMEMultipart):
return content
return super()._create_mime_attachment(content, mimetype)
@app.task(base=TransactionAwareTask, bind=True)
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int=None, position: int=None, headers: dict=None, bcc: List[str]=None,
invoices: List[int]=None, order: int=None, attach_tickets=False, user=None,
attach_ical=False) -> bool:
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
html_with_cid, cid_images = replace_images_with_cid_paths(html)
email = attach_cid_images(email, cid_images, verify_ssl=True)
email.attach_alternative(html_with_cid, "text/html")
html_message.attach(SafeMIMEText(html_with_cid, 'html', settings.DEFAULT_CHARSET))
attach_cid_images(html_message, cid_images, verify_ssl=True)
email.attach_alternative(html_message, "multipart/related")
if user:
user = User.objects.get(pk=user)
@@ -261,11 +281,12 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
for inv in invoices:
if inv.file:
try:
email.attach(
'{}.pdf'.format(inv.number),
inv.file.file.read(),
'application/pdf'
)
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
@@ -345,6 +366,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
if isinstance(e, (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, ssl.SSLError, OSError)):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
if order:
order.log_action(
'pretix.event.order.email.error',
@@ -408,8 +431,6 @@ def attach_cid_images(msg, cid_images, verify_ssl=True):
except:
logger.exception("ERROR attaching CID image %s[%s]" % (cid, image))
return msg
def encoder_linelength(msg):
"""

View File

@@ -0,0 +1,173 @@
import csv
import io
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.timezone import now
from django.utils.translation import gettext as _
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import (
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
User,
)
from pretix.base.orderimport import get_all_columns
from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.signals import order_paid, order_placed
from pretix.celery_app import app
class DataImportError(LazyLocaleException):
def __init__(self, *args):
msg = args[0]
msgargs = args[1] if len(args) > 1 else None
self.args = args
if msgargs:
msg = _(msg) % msgargs
else:
msg = _(msg)
super().__init__(msg)
def parse_csv(file, length=None):
data = file.read(length)
try:
import chardet
charset = chardet.detect(data)['encoding']
except ImportError:
charset = file.charset
data = data.decode(charset or 'utf-8')
# If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n')
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
if dialect is None:
return None
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
return reader
def setif(record, obj, attr, setting):
if setting.startswith('csv:'):
setattr(obj, attr, record[setting[4:]] or '')
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
def import_orders(event: Event, fileid: str, settings: dict, locale: str, user) -> None:
# TODO: quotacheck?
cf = CachedFile.objects.get(id=fileid)
user = User.objects.get(pk=user)
with language(locale):
cols = get_all_columns(event)
parsed = parse_csv(cf.file)
orders = []
order = None
data = []
# Run validation
for i, record in enumerate(parsed):
values = {}
for c in cols:
val = c.resolve(settings, record)
try:
values[c.identifier] = c.clean(val, values)
except ValidationError as e:
raise DataImportError(
_(
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
)
)
data.append(values)
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
# shorter. We'll see what works better in reality…
for i, record in enumerate(data):
try:
if order is None or settings['orders'] == 'many':
order = Order(
event=event,
testmode=settings['testmode'],
)
order.meta_info = {}
order._positions = []
order._address = InvoiceAddress()
order._address.name_parts = {'_scheme': event.settings.name_scheme}
orders.append(order)
position = OrderPosition()
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
position.meta_info = {}
order._positions.append(position)
position.assign_pseudonymization_id()
for c in cols:
c.assign(record.get(c.identifier), order, position, order._address)
except ImportError as e:
raise ImportError(
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
)
# quota check?
with event.lock():
with transaction.atomic():
for o in orders:
o.total = sum([c.price for c in o._positions]) # currently no support for fees
if o.total == Decimal('0.00'):
o.status = Order.STATUS_PAID
o.save()
OrderPayment.objects.create(
local_id=1,
order=o,
amount=Decimal('0.00'),
provider='free',
info='{}',
payment_date=now(),
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
elif settings['status'] == 'paid':
o.status = Order.STATUS_PAID
o.save()
OrderPayment.objects.create(
local_id=1,
order=o,
amount=o.total,
provider='manual',
info='{}',
payment_date=now(),
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
else:
o.status = Order.STATUS_PENDING
o.save()
for p in o._positions:
p.order = o
p.save()
o._address.order = o
o._address.save()
for c in cols:
c.save(o)
o.log_action(
'pretix.event.order.placed',
user=user,
data={'source': 'import'}
)
for o in orders:
with language(o.locale):
order_placed.send(event, order=o)
if o.status == Order.STATUS_PAID:
order_paid.send(event, order=o)
gen_invoice = invoice_qualified(o) and (
(event.settings.get('invoice_generate') == 'True') or
(event.settings.get('invoice_generate') == 'paid' and o.status == Order.STATUS_PAID)
) and not o.invoices.last()
if gen_invoice:
generate_invoice(o, trigger_pdf=True)
cf.delete()

View File

@@ -7,9 +7,10 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db.models import Exists, F, Max, OuterRef, Q, Sum
from django.db.models.functions import Greatest
from django.db.models import Exists, F, Max, Min, OuterRef, Q, Sum
from django.db.models.functions import Coalesce, Greatest
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
@@ -69,6 +70,8 @@ error_messages = {
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
'number of times allowed. We removed this item from your cart.'),
'voucher_budget_used': _('The voucher code used for one of the items in your cart has already been too often. We '
'adjusted the price of the item in your cart.'),
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
'from your cart.'),
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
@@ -289,7 +292,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
if not order.cancel_allowed():
raise OrderError(_('You cannot cancel this order.'))
i = order.invoices.filter(is_cancellation=False).last()
i = order.invoices.filter(is_cancellation=False, refered__isnull=True).last()
if i:
generate_cancellation(i)
@@ -359,6 +362,31 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)):
try:
with transaction.atomic():
p.payment_provider.cancel_payment(p)
order.log_action(
'pretix.event.order.payment.canceled',
{
'local_id': p.local_id,
'provider': p.provider,
},
user=user,
auth=api_token or oauth_application or device
)
except PaymentException as e:
order.log_action(
'pretix.event.order.payment.canceled.failed',
{
'local_id': p.local_id,
'provider': p.provider,
'error': str(e)
},
user=user,
auth=api_token or oauth_application or device
)
order_canceled.send(order.event, order=order)
return order.pk
@@ -392,13 +420,15 @@ def _check_date(event: Event, now_dt: datetime):
raise OrderError(error_messages['ended'])
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None):
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
sales_channel='web'):
err = None
errargs = None
_check_date(event, now_dt)
products_seen = Counter()
changed_prices = {}
v_budget = {}
deleted_positions = set()
seats_seen = set()
@@ -441,6 +471,20 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if cp.voucher.budget is not None:
if cp.voucher not in v_budget:
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
disc = cp.price_before_voucher - cp.price
if disc > v_budget[cp.voucher]:
new_disc = max(0, v_budget[cp.voucher])
cp.price = cp.price + (disc - new_disc)
cp.save()
err = err or error_messages['voucher_budget_used']
v_budget[cp.voucher] -= new_disc
continue
else:
v_budget[cp.voucher] -= disc
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
err = err or error_messages['some_subevent_not_started']
delete(cp)
@@ -484,9 +528,9 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break
if cp.seat:
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every time, since we absolutely
# can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp) or cp.seat.blocked:
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
# time, since we absolutely can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel):
err = err or error_messages['seat_unavailable']
cp.delete()
continue
@@ -495,6 +539,11 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
# Other checks are not necessary
continue
max_discount = None
if cp.price_before_voucher is not None and cp.voucher in v_budget:
current_discount = cp.price_before_voucher - cp.price
max_discount = max(v_budget[cp.voucher] + current_discount, 0)
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
@@ -502,7 +551,9 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
except ItemBundle.DoesNotExist:
bprice = cp.price
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
invoice_address=address, force_custom_price=True)
invoice_address=address, force_custom_price=True, max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
bundled_sum = 0
@@ -512,7 +563,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum)
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount)
if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
if price is False or len(quotas) == 0:
err = err or error_messages['unavailable']
@@ -525,6 +583,11 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if pbv is not None and pbv.gross != price.gross:
cp.price_before_voucher = pbv.gross
else:
cp.price_before_voucher = None
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
cp.price = price.gross
cp.includes_tax = bool(price.rate)
@@ -775,12 +838,15 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
lockfn = event.lock
with lockfn() as now_dt:
positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons'))
positions = list(
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
)
positions.sort(key=lambda k: position_ids.index(k.pk))
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions, address=addr)
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel)
order, payment = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
gift_cards=gift_cards, shown_total=shown_total)
@@ -832,14 +898,14 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
@receiver(signal=periodic_task)
@scopes_disabled()
def expire_orders(sender, **kwargs):
eventcache = {}
event_id = None
expire = None
for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING,
require_approval=False).select_related('event'):
expire = eventcache.get(o.event.pk, None)
if expire is None:
require_approval=False).select_related('event').order_by('event_id'):
if o.event_id != event_id:
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
eventcache[o.event.pk] = expire
event_id = o.event_id
if expire:
mark_order_expired(o)
@@ -847,31 +913,35 @@ def expire_orders(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
def send_expiry_warnings(sender, **kwargs):
eventcache = {}
today = now().replace(hour=0, minute=0, second=0)
days = None
settings = None
event_id = None
for o in Order.objects.filter(
expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING,
datetime__lte=now() - timedelta(hours=2), require_approval=False
).only('pk'):
with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
# Race condition
continue
eventsettings = eventcache.get(o.event.pk, None)
if eventsettings is None:
eventsettings = o.event.settings
eventcache[o.event.pk] = eventsettings
).only('pk', 'event_id', 'expires').order_by('event_id'):
if event_id != o.event_id:
settings = o.event.settings
days = cache.get_or_set('{}:{}:setting_mail_days_order_expire_warning'.format('event', o.event_id),
default=lambda: settings.get('mail_days_order_expire_warning', as_type=int),
timeout=3600)
event_id = o.event_id
if days and (o.expires - today).days <= days:
with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
# Race condition
continue
days = eventsettings.get('mail_days_order_expire_warning', as_type=int)
if days and (o.expires - today).days <= days:
with language(o.locale):
o.expiry_reminder_sent = True
o.save(update_fields=['expiry_reminder_sent'])
email_template = eventsettings.mail_text_order_expire_warning
email_template = settings.mail_text_order_expire_warning
email_context = get_email_context(event=o.event, order=o)
if eventsettings.payment_term_expire_automatically:
if settings.payment_term_expire_automatically:
email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code}
else:
email_subject = _('Your order is pending payment: %(code)s') % {'code': o.code}
@@ -889,54 +959,76 @@ def send_expiry_warnings(sender, **kwargs):
@scopes_disabled()
def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
qs = Order.objects.annotate(
first_date=Coalesce(
Min('all_positions__subevent__date_from'),
F('event__date_from')
)
).filter(
status=Order.STATUS_PAID,
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
first_date__gte=today,
).only('pk', 'event_id').order_by('event_id')
event_id = None
days = None
event = None
for e in Event.objects.filter(date_from__gte=today):
for o in qs:
if o.event_id != event_id:
days = o.event.settings.get('mail_days_download_reminder', as_type=int)
event = o.event
event_id = o.event_id
days = e.settings.get('mail_days_download_reminder', as_type=int)
if days is None:
continue
reminder_date = (e.date_from - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < reminder_date:
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < reminder_date or o.datetime > reminder_date:
continue
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False, datetime__lte=now() - timedelta(hours=2)).only('pk'):
with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
continue
with language(o.locale):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = e.settings.mail_text_download_reminder
email_context = get_email_context(event=e, order=o)
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
except SendMailException:
logger.exception('Reminder email could not be sent')
with transaction.atomic():
o = Order.objects.select_for_update().get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
if not all([r for rr, r in allow_ticket_download.send(event, order=o)]):
continue
if e.settings.mail_send_download_reminder_attendee:
for p in o.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = e.settings.mail_text_download_reminder_attendee
email_context = get_email_context(event=e, order=o, position=p)
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
except SendMailException:
logger.exception('Reminder email could not be sent to attendee')
with language(o.locale):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_context = get_email_context(event=event, order=o)
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
except SendMailException:
logger.exception('Reminder email could not be sent')
if event.settings.mail_send_download_reminder_attendee:
for p in o.positions.all():
if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
if now() < reminder_date:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
except SendMailException:
logger.exception('Reminder email could not be sent to attendee')
def notify_user_changed_order(order, user=None, auth=None):
@@ -1012,6 +1104,21 @@ class OrderChangeManager:
self._operations.append(self.ItemOperation(position, item, variation))
def change_seat(self, position: OrderPosition, seat: Seat):
if isinstance(seat, str):
subev = None
if self.event.has_subevents:
subev = position.subevent
for p in self._operations:
if isinstance(p, self.SubeventOperation) and p.position == position:
subev = p.subevent
try:
seat = Seat.objects.get(
event=self.event,
subevent=subev,
seat_guid=seat
)
except Seat.DoesNotExist:
raise OrderError(error_messages['seat_invalid'])
if position.seat:
self._seatdiff.subtract([position.seat])
if seat:
@@ -1095,6 +1202,19 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
subevent: SubEvent = None, seat: Seat = None):
if isinstance(seat, str):
if not seat:
seat = None
else:
try:
seat = Seat.objects.get(
event=self.event,
subevent=subevent,
seat_guid=seat
)
except Seat.DoesNotExist:
raise OrderError(error_messages['seat_invalid'])
if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
@@ -1118,7 +1238,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['seat_required'])
elif not seated and seat:
raise OrderError(self.error_messages['seat_forbidden'])
if seat and subevent and seat.subevent_id != subevent:
if seat and subevent and seat.subevent_id != subevent.pk:
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name))
new_quotas = (variation.quotas.filter(subevent=subevent)
@@ -1145,7 +1265,7 @@ class OrderChangeManager:
for seat, diff in self._seatdiff.items():
if diff <= 0:
continue
if not seat.is_available() or diff > 1:
if not seat.is_available(sales_channel=self.order.sales_channel) or diff > 1:
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
if self.event.has_subevents:
@@ -1183,20 +1303,49 @@ class OrderChangeManager:
self.order.status = Order.STATUS_PAID
self.order.save()
elif self.open_payment:
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
self.open_payment.save()
self.order.log_action('pretix.event.order.payment.canceled', {
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
}, user=self.user, auth=self.auth)
try:
with transaction.atomic():
self.open_payment.payment_provider.cancel_payment(self.open_payment)
self.order.log_action(
'pretix.event.order.payment.canceled',
{
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
},
user=self.user,
auth=self.auth
)
except PaymentException as e:
self.order.log_action(
'pretix.event.order.payment.canceled.failed',
{
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
'error': str(e)
},
user=self.user,
auth=self.auth
)
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
if self.open_payment:
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
self.open_payment.save()
self.order.log_action('pretix.event.order.payment.canceled', {
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
}, user=self.user, auth=self.auth)
try:
with transaction.atomic():
self.open_payment.payment_provider.cancel_payment(self.open_payment)
self.order.log_action('pretix.event.order.payment.canceled', {
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
}, user=self.user, auth=self.auth)
except PaymentException as e:
self.order.log_action(
'pretix.event.order.payment.canceled.failed',
{
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
'error': str(e)
},
user=self.user,
auth=self.auth,
)
def _check_paid_to_free(self):
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
@@ -1252,6 +1401,16 @@ class OrderChangeManager:
op.position.item = op.item
op.position.variation = op.variation
op.position._calculate_tax()
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
get_price(
op.position.item, op.position.variation,
subevent=op.position.subevent,
custom_price=op.position.price,
invoice_address=self._invoice_address
).gross
)
op.position.save()
elif isinstance(op, self.SeatOperation):
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
@@ -1275,6 +1434,16 @@ class OrderChangeManager:
})
op.position.subevent = op.subevent
op.position.save()
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
get_price(
op.position.item, op.position.variation,
subevent=op.position.subevent,
custom_price=op.position.price,
invoice_address=self._invoice_address
).gross
)
elif isinstance(op, self.FeeValueOperation):
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
'fee': op.fee.pk,
@@ -1726,8 +1895,22 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
if open_payment and open_payment.state in (OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED):
open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
open_payment.save(update_fields=['state'])
try:
with transaction.atomic():
open_payment.payment_provider.cancel_payment(open_payment)
order.log_action('pretix.event.order.payment.canceled', {
'local_id': open_payment.local_id,
'provider': open_payment.provider,
})
except PaymentException as e:
order.log_action(
'pretix.event.order.payment.canceled.failed',
{
'local_id': open_payment.local_id,
'provider': open_payment.provider,
'error': str(e)
},
)
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
order.save(update_fields=['total'])
@@ -1753,7 +1936,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
if recreate_invoices:
i = order.invoices.filter(is_cancellation=False).last()
if i and order.total != oldtotal:
if i and order.total != oldtotal and not i.canceled:
generate_cancellation(i)
generate_invoice(order)

View File

@@ -12,7 +12,8 @@ def get_price(item: Item, variation: ItemVariation = None,
voucher: Voucher = None, custom_price: Decimal = None,
subevent: SubEvent = None, custom_price_is_net: bool = False,
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00')) -> TaxedPrice:
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
max_discount: Decimal = None) -> TaxedPrice:
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
@@ -32,7 +33,7 @@ def get_price(item: Item, variation: ItemVariation = None,
price = subevent.var_price_overrides[variation.pk]
if voucher:
price = voucher.calculate_price(price)
price = voucher.calculate_price(price, max_discount=max_discount)
if item.tax_rule:
tax_rule = item.tax_rule

View File

@@ -1,32 +1,46 @@
from django.db.models import Count
from django.db.models import Count, Q
from django.utils.translation import ugettext_lazy as _
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import CartPosition, Seat
class SeatProtected(Exception):
pass
class SeatProtected(LazyLocaleException):
def __init__(self, *args):
msg = args[0]
msgargs = args[1] if len(args) > 1 else None
self.args = args
if msgargs:
msg = _(msg) % msgargs
else:
msg = _(msg)
super().__init__(msg)
def validate_plan_change(event, subevent, plan):
current_taken_seats = set(
event.seats.select_related('product')
.annotate(has_op=Count('orderposition'))
.filter(subevent=subevent, has_op=True)
.values_list('seat_guid', flat=True)
event.seats.select_related('product').annotate(
has_op=Count('orderposition')
).annotate(has_v=Count('vouchers')).filter(
subevent=subevent,
).filter(
Q(has_v=True) | Q(has_op=True)
).values_list('seat_guid', flat=True)
)
new_seats = {
ss.guid for ss in plan.iter_all_seats()
} if plan else set()
leftovers = list(current_taken_seats - new_seats)
if leftovers:
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
'already sold.').format(leftovers[0]))
raise SeatProtected(_('You can not change the plan since seat "%s" is not present in the new plan and is '
'already sold.'), leftovers[0])
def generate_seats(event, subevent, plan, mapping):
current_seats = {}
for s in event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent):
for s in event.seats.select_related('product').annotate(
has_op=Count('orderposition'), has_v=Count('vouchers')
).filter(subevent=subevent):
if s.seat_guid in current_seats:
s.delete() # Duplicates should not exist
else:
@@ -50,6 +64,7 @@ def generate_seats(event, subevent, plan, mapping):
update(seat, 'row_name', ss.row),
update(seat, 'seat_number', ss.number),
update(seat, 'zone_name', ss.zone),
update(seat, 'sorting_rank', ss.sorting_rank),
])
if updated:
seat.save()
@@ -62,13 +77,17 @@ def generate_seats(event, subevent, plan, mapping):
row_name=ss.row,
seat_number=ss.number,
zone_name=ss.zone,
sorting_rank=ss.sorting_rank,
product=p,
))
for s in current_seats.values():
if s.has_op:
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
'already sold.').format(s.name))
raise SeatProtected(_('You can not change the plan since seat "%s" is not present in the new plan and is '
'already sold.', s.name))
if s.has_v:
raise SeatProtected(_('You can not change the plan since seat "%s" is not present in the new plan and is '
'already used in a voucher.', s.name))
Seat.objects.bulk_create(create_seats)
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()

View File

@@ -89,8 +89,9 @@ def preview(event: int, provider: str):
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
sample = {k: str(v) for k, v in scheme['sample'].items()}
p = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p)
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p)
s = event.subevents.first()
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))

View File

@@ -85,6 +85,10 @@ DEFAULTS = {
'default': 'True',
'type': bool,
},
'invoice_include_expire_date': {
'default': 'False',
'type': bool,
},
'invoice_numbers_consecutive': {
'default': 'True',
'type': bool,
@@ -137,6 +141,10 @@ DEFAULTS = {
'default': 'True',
'type': bool
},
'payment_resellers__restrict_to_sales_channels': {
'default': ['resellers'],
'type': list
},
'payment_term_accept_late': {
'default': 'True',
'type': bool
@@ -652,6 +660,10 @@ Your {event} team"""))
'default': None,
'type': File
},
'og_image': {
'default': None,
'type': File
},
'invoice_logo_image': {
'default': None,
'type': File
@@ -664,10 +676,25 @@ Your {event} team"""))
'default': '',
'type': LazyI18nString
},
'checkout_email_helptext': {
'default': LazyI18nString.from_gettext(ugettext_noop(
'Make sure to enter a valid email address. We will send you an order '
'confirmation including a link that you need to access your order later.'
)),
'type': LazyI18nString
},
'order_import_settings': {
'default': '{}',
'type': dict
},
'organizer_info_text': {
'default': '',
'type': LazyI18nString
},
'event_team_provisioning': {
'default': 'True',
'type': bool
},
'update_check_ack': {
'default': 'False',
'type': bool
@@ -723,7 +750,15 @@ Your {event} team"""))
'name_scheme': {
'default': 'full',
'type': str
}
},
'giftcard_length': {
'default': settings.ENTROPY['giftcard_secret'],
'type': int
},
'seating_allow_blocked_seats_for_channel': {
'default': [],
'type': list
},
}
PERSON_NAME_TITLE_GROUPS = OrderedDict([
('english_common', (_('Most common English titles'), (

View File

@@ -622,3 +622,22 @@ order_split = EventPluginSignal(
This signal is sent out when an order is split into two orders and allows you to copy related models
to the new order. You will be passed the old order as ``original`` and the new order as ``split_order``.
"""
invoice_line_text = EventPluginSignal(
providing_args=["position"]
)
"""
This signal is sent out when an invoice is built for an order. You can return additional text that
should be shown on the invoice for the given ``position``.
"""
order_import_columns = EventPluginSignal(
providing_args=[]
)
"""
This signal is sent out if the user performs an import of orders from an external source. You can use this
to define additional columns that can be read during import. You are expected to return a list of instances of
``ImportColumn`` subclasses.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""

View File

@@ -139,6 +139,14 @@
text-decoration: none;
color: {{ color }};
}
{% if rtl %}
body {
direction: rtl;
}
.content table td {
text-align: right;
}
{% endif %}
{% block addcss %}{% endblock %}
</style>
@@ -158,7 +166,7 @@
<table class="layout" width="600" border="0" cellspacing="0">
<!--[if !mso]><!-- -->
<tr>
<td>
<td style="line-height: 0">
<img class="wide" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAA8CAAAAACf95tlAAAAAXNCSVQI5gpbmQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAG/SURBVHja7dvRboMwDIXhvf/DLiQQAwkku9+qDgq2hPyfN6j1qTlx06/uMunbLMnnhL98fuzRDtYILEeZ7GBNwAIWsIB1LdkOVgaWo4gdLAGWo6x2sFZgOUq1g1WB5SjNDlYDlqcEK1dDB5anmK3eE7C4FnIpBNbVFLo7sB7d3huwKFlULGA9pWQJsJxls4G1ActbooWr2IHlLbMFrBlY7rJbwNqBxb2QZ8nAuiUGO9ICLOo71R1YN0X9td8KLJ8ZeDEDrAd+Za3A4mLIz4TAujGqv+tUYPmN4v8LcweW3zS1t++hActzCrtRYD3pMJQOLOeJ7NyBpZFdoWaFDVjuJ6BRswpTBZbCAn5hpsDq/fbHpDMTBZbC1TAzT2ApyMIVsDROQ2GWwFJo8PR2YP3eOtywzwrsGYD1J9vlHXzcmSKw7q/wU2OEwHpdtALHILA00jJfV8DSaVofvYOPlckB658sp/8VNrBkANahqnXqfhhXJgasgymHD8REZwfWmezzga+tQdhcAet0qry1FYV3osD6dP1QJL3YbYUkhfUCsK6einWRPI0pxjROWZbK+QcsAiwCLEKARYBFgEXIu/wAYbjtwujw8KwAAAAASUVORK5CYII="
style="max-height: 60px;">
</td>
@@ -188,7 +196,7 @@
{% endblock %}
<!--[if !mso]><!-- -->
<tr>
<td>
<td style="line-height: 0">
<br>
<img class="wide" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAA8CAYAAAC6nMS5AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAPnSURBVHic7d3dbuJIEAbQsg2Ecd7/TQeDf3svVuFmdjJLxsGm+xwJKXcpqS76U3VTVCmlFAAArKbeugAAgNwIWAAAKxOwAABWJmABAKxMwAIAWNlh6wIAIiKWZYllWWKe5/vfEREppfsnIqKqqvsnIqKu66jrOpqmuf8NsDUBC3i6lFJM0xTjOMY0TbEsS6y1MaaqqqjrOg6HQxyPxzgcDvcwBvAslT1YwDN8BKpxHGOe56f+76Zp4ng83gMXwHcTsIBvsyxLDMMQfd/fr/y2Vtd1nE6nOJ1O0TTN1uUAmRKwgNV9hKppmrYu5VOHwyHO53Mcj8etSwEyI2ABqxmGIW6329OvAP9WXddxPp/j7e1t61KATAhYwF/r+z5ut9turgG/StAC1iJgAV82z3N0Xbf7q8BHNU0Tbdt6EA98mYAFPCylFNfrNfq+37qUb3U6naJtW2segIcJWMBDhmGIrutW21u1d1VVRdu2cTqdti4FeCECFvC/lDK1+h3TLOARAhbwR/M8x+VyeblvB66taZp4f3+3Pwv4IwEL+NQ4jnG5XIq5EvyTqqri/f3d7izgUwIW8FvDMMTlctm6jF1q29Y6B+C3BCzgP91ut7her1uXsWvn8zl+/PixdRnADglYwC+6riv2Mfuj3t7eom3brcsAdqbeugBgX4Srx/R9H13XbV0GsDMCFnB3u92Eqy/4+KkggA8CFhAR/z5o9+bq60reEQb8SsAC7qsY+Dtd18U4jluXAeyAgAWFW5ZFuFqRhaxAhIAFxfv586cloitKKVnMCghYULKu60xbvsE8z96zQeEELCjUOI4eZX+jvu+9x4KCCVhQoI9rLL6Xq0Iol4AFBbperw7+J0gpuSqEQglYUJh5nl0NPlHf9zFN09ZlAE8mYEFh/KzL85liQXkELCiIaco2pmmKYRi2LgN4IgELCuL38rZjigVlEbCgEMMwxLIsW5dRrGVZTLGgIAIWFML0ant6AOUQsKAA4zja2L4D8zxbPgqFELCgACYn+6EXUAYBCzK3LItvDu7INE3ewkEBBCzInIfV+6MnkD8BCzLnMN8fPYH8CViQsXmePW7fIX2B/AlYkDGTkv3SG8ibgAUZ87h9v/QG8iZgQaZSSg7xHZumKVJKW5cBfBMBCzIlXO2fHkG+BCzIlMN7//QI8iVgQaYc3vunR5AvAQsyZQ3A/tnoDvkSsCBDKSUPqF/Asiz6BJkSsCBDplevQ68gTwIWZMjV0+vQK8iTgAUZMhV5HXoFeRKwIEPe9bwOvYI8CViQIYf269AryJOABQCwMgELMmQq8jr0CvIkYEGGHNqvQ68gT/8AETAn3pyLgvsAAAAASUVORK5CYII="
style="max-height: 60px;">

View File

@@ -1,8 +1,10 @@
<!--[if !mso]><!-- -->
<tr>
<td>
<img class="wide" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAAoBAMAAADQ9ZkHAAAAA3NCSVQICAjb4U/gAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAC1QTFRF7u7u7+/v8PDw8fHx8vLy9PT09fX19/f3+Pj4+fn5+vr6/Pz8/f39/v7+////BLnnfgAAAKJJREFUaN7t1cGtgUEARtFfQkREDypQihpUoIQXpahADUqRiIgINdjehcXbSTjf8s5mchYzw9P+vQEBLFiwYMGChQAWLFiwYMFCAAsWLFhfipX9pe/S1+nH9EX6OX2Wfk2fpN/TR73QMgeH9E36Nn2fvko/pc/TL+nT9Fv6OP0xvB8sWLA+g+XZ9hvCggULFiwEsGDBggULFgJYsGDBgvXzewGTOlWA3NB0eQAAAABJRU5ErkJggg=="
style="max-height: 40px;">
<div style="line-height: 18px;height: 18px;">&nbsp;</div>
<img class="wide" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAAEBAMAAACgm1xKAAABhGlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV/TSkUrDu0g4pChOlkQleKoVShChVArtOpgcukXNGlIUlwcBdeCgx+LVQcXZ10dXAVB8APExdVJ0UVK/F9aaBHjwXE/3t173L0DhEaFaVZgAtB020wnE2I2tyoGXxFCAP0IIy4zy5iTpBQ8x9c9fHy9i/Es73N/jgE1bzHAJxLPMsO0iTeI45u2wXmfOMJKskp8Tjxu0gWJH7mutPiNc9FlgWdGzEx6njhCLBa7WOliVjI14mniqKrplC9kW6xy3uKsVWqsfU/+wlBeX1nmOs0RJLGIJUgQoaCGMiqwEaNVJ8VCmvYTHv5h1y+RSyFXGYwcC6hCg+z6wf/gd7dWYWqylRRKAD0vjvMxCgR3gWbdcb6PHad5AvifgSu94682gJlP0usdLXoEDG4DF9cdTdkDLneAoSdDNmVX8tMUCgXg/Yy+KQeEb4G+tVZv7X2cPgAZ6ip1AxwcAmNFyl73eHdvd2//nmn39wNhNnKgJpT5BQAAAC1QTFRF7u7u7+/v8PDw8fHx8vLy9PT09fX19/f3+Pj4+fn5+vr6/Pz8/f39/v7+////BLnnfgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAAd0SU1FB+MMBAsUDD3bzUUAAABhSURBVDjLY2BAgLp3CNCAJO6HJH4ASZwXSfwxkjgnkvhzJHFWJPHXSOKMSOLvFJAk1iGJJyCJ5yGJL0AS10MSf4Akzo0k/hRJnB1J/CWSOAuS+FsG7GA0sEYDazSwBiSwAPzzGpfLqBMlAAAAAElFTkSuQmCC"
style="max-height: 4px;">
<div style="line-height: 18px;height: 18px;">&nbsp;</div>
</td>
</tr>
<!--<![endif]-->

View File

@@ -1,8 +1,11 @@
from urllib.parse import urlencode
from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator, validate_email
from django.core.validators import (
MaxValueValidator, MinValueValidator, RegexValidator, validate_email,
)
from django.db.models import Q
from django.forms import formset_factory
from django.urls import reverse
@@ -22,7 +25,7 @@ from pytz import common_timezones, timezone
from pretix.base.channels import get_all_sales_channels
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.models import Event, Organizer, TaxRule
from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventMetaValue, SubEvent
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
@@ -101,6 +104,16 @@ class EventWizardBasicsForm(I18nModelForm):
required=False
)
team = forms.ModelChoiceField(
label=_("Grant access to team"),
help_text=_("You are allowed to create events under this organizer, however you do not have permission "
"to edit all events under this organizer. Please select one of your existing teams that will"
" be granted access to this event."),
queryset=Team.objects.none(),
required=False,
empty_label=_('Create a new team for this event with me as the only member')
)
class Meta:
model = Event
fields = [
@@ -133,10 +146,11 @@ class EventWizardBasicsForm(I18nModelForm):
self.organizer = kwargs.pop('organizer')
self.locales = kwargs.get('locales')
self.has_subevents = kwargs.pop('has_subevents')
kwargs.pop('user')
self.user = kwargs.pop('user')
kwargs.pop('session')
super().__init__(*args, **kwargs)
self.initial['timezone'] = get_current_timezone_name()
if 'timezone' not in self.initial:
self.initial['timezone'] = get_current_timezone_name()
self.fields['locale'].choices = [(a, b) for a, b in settings.LANGUAGES if a in self.locales]
self.fields['location'].widget.attrs['rows'] = '3'
self.fields['location'].widget.attrs['placeholder'] = _(
@@ -147,6 +161,15 @@ class EventWizardBasicsForm(I18nModelForm):
del self.fields['presale_start']
del self.fields['presale_end']
if self.has_control_rights(self.user, self.organizer):
del self.fields['team']
else:
self.fields['team'].queryset = self.user.teams.filter(organizer=self.organizer)
if not self.organizer.settings.get("event_team_provisioning", True, as_type=bool):
self.fields['team'].required = True
self.fields['team'].empty_label = None
self.fields['team'].initial = 0
def clean(self):
data = super().clean()
if data.get('locale') not in self.locales:
@@ -179,6 +202,13 @@ class EventWizardBasicsForm(I18nModelForm):
)
return slug
@staticmethod
def has_control_rights(user, organizer):
return user.teams.filter(
organizer=organizer, all_events=True, can_change_event_settings=True, can_change_items=True,
can_change_orders=True, can_change_vouchers=True
).exists()
class EventChoiceMixin:
def label_from_instance(self, obj):
@@ -244,12 +274,18 @@ class EventMetaValueForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.fields['value'].required = False
self.fields['value'].widget.attrs['placeholder'] = self.property.default
self.fields['value'].widget.attrs['data-typeahead-url'] = (
reverse('control:events.meta.typeahead') + '?' + urlencode({
'property': self.property.name,
'organizer': self.property.organizer.slug,
})
)
class Meta:
model = EventMetaValue
fields = ['value']
widgets = {
'value': forms.TextInput
'value': forms.TextInput()
}
@@ -488,11 +524,26 @@ class EventSettingsForm(SettingsForm):
help_text=_('If you provide a logo image, we will by default not show your events name and date '
'in the page header. We will show your logo with a maximal height of 120 pixels.')
)
og_image = ExtFileField(
label=_('Social media image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
required=False,
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
'only the center square is shown. If you do not fill this, we will use the logo given above.')
)
frontpage_text = I18nFormField(
label=_("Frontpage text"),
required=False,
widget=I18nTextarea
)
checkout_email_helptext = I18nFormField(
label=_("Help text of the email field"),
required=False,
widget_kwargs={'attrs': {'rows': '2'}},
widget=I18nTextarea
)
presale_has_ended_text = I18nFormField(
label=_("End of presale text"),
required=False,
@@ -632,6 +683,9 @@ class PaymentSettingsForm(SettingsForm):
"you use slow payment methods like bank transfer, we recommend 14 days. If you only use real-time "
"payment methods, we recommend still setting two or three days to allow people to retry failed "
"payments."),
validators=[MinValueValidator(0),
MaxValueValidator(1000000)]
)
payment_term_last = RelativeDateField(
label=_('Last date of payments'),
@@ -719,7 +773,7 @@ class ProviderForm(SettingsForm):
v.set_event(self.obj)
if hasattr(v, '_as_type'):
self.initial[k] = self.obj.settings.get(k, as_type=v._as_type)
self.initial[k] = self.obj.settings.get(k, as_type=v._as_type, default=v.initial)
def clean(self):
cleaned_data = super().clean()
@@ -796,7 +850,9 @@ class InvoiceSettingsForm(SettingsForm):
help_text=_("This will be prepended to invoice numbers. If you leave this field empty, your event slug will "
"be used followed by a dash. Attention: If multiple events within the same organization use the "
"same value in this field, they will share their number range, i.e. every full number will be "
"used at most once over all of your events. This setting only affects future invoices."),
"used at most once over all of your events. This setting only affects future invoices. You can "
"use %Y (with century) %y (without century) to insert the year of the invoice, or %m and %d for "
"the day of month."),
required=False,
)
invoice_numbers_prefix_cancellations = forms.CharField(
@@ -829,6 +885,11 @@ class InvoiceSettingsForm(SettingsForm):
label=_("Show attendee names on invoices"),
required=False
)
invoice_include_expire_date = forms.BooleanField(
label=_("Show expiration date of order"),
help_text=_("The expiration date will not be shown if the invoice is generated after the order is paid."),
required=False
)
invoice_email_attachment = forms.BooleanField(
label=_("Attach invoices to emails"),
help_text=_("If invoices are automatically generated for all orders, they will be attached to the order "
@@ -1392,14 +1453,8 @@ class WidgetCodeForm(forms.Form):
class EventDeleteForm(forms.Form):
error_messages = {
'pw_current_wrong': _("The password you entered was not correct."),
'slug_wrong': _("The slug you entered was not correct."),
}
user_pw = forms.CharField(
max_length=255,
label=_("Your password"),
widget=forms.PasswordInput()
)
slug = forms.CharField(
max_length=255,
label=_("Event slug"),
@@ -1407,19 +1462,8 @@ class EventDeleteForm(forms.Form):
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
def clean_user_pw(self):
user_pw = self.cleaned_data.get('user_pw')
if not check_password(user_pw, self.user.password):
raise forms.ValidationError(
self.error_messages['pw_current_wrong'],
code='pw_current_wrong',
)
return user_pw
def clean_slug(self):
slug = self.cleaned_data.get('slug')
if slug != self.event.slug:

View File

@@ -18,6 +18,7 @@ from pretix.base.models import (
)
from pretix.base.signals import register_payment_providers
from pretix.control.forms.widgets import Select2
from pretix.control.signals import order_search_filter_q
from pretix.helpers.database import FixedOrderBy, rolledback_transaction
from pretix.helpers.i18n import i18ncomp
@@ -139,7 +140,7 @@ class OrderFilterForm(FilterForm):
)
).values('id')
qs = qs.annotate(has_pos=Exists(matching_positions)).filter(
mainq = (
code
| Q(email__icontains=u)
| Q(invoice_address__name_cached__icontains=u)
@@ -148,6 +149,11 @@ class OrderFilterForm(FilterForm):
| Q(comment__icontains=u)
| Q(has_pos=True)
)
for recv, q in order_search_filter_q.send(sender=getattr(self, 'event', None), query=u):
mainq = mainq | q
qs = qs.annotate(has_pos=Exists(matching_positions)).filter(
mainq
)
if fdata.get('status'):
s = fdata.get('status')
@@ -289,7 +295,7 @@ class EventOrderFilterForm(OrderFilterForm):
answers = QuestionAnswer.objects.filter(
question_id=q.pk,
orderposition__order_id=OuterRef('pk'),
answer__iexact=fdata.get('answer')
answer__exact=fdata.get('answer')
)
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
@@ -705,6 +711,8 @@ class CheckInFilterForm(FilterForm):
'-timestamp': (FixedOrderBy(F('last_checked_in'), nulls_last=True, descending=True), '-order__code'),
'item': ('item__name', 'variation__value', 'order__code'),
'-item': ('-item__name', '-variation__value', '-order__code'),
'seat': ('seat__sorting_rank', 'seat__guid'),
'-seat': ('-seat__sorting_rank', '-seat__guid'),
'name': {'_order': F('display_name').asc(nulls_first=True),
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
'-name': {'_order': F('display_name').desc(nulls_last=True),
@@ -843,6 +851,32 @@ class UserFilterForm(FilterForm):
class VoucherFilterForm(FilterForm):
orders = {
'code': 'code',
'-code': '-code',
'redeemed': 'redeemed',
'-redeemed': '-redeemed',
'valid_until': 'valid_until',
'-valid_until': '-valid_until',
'tag': 'tag',
'-tag': '-tag',
'item': (
'seat__sorting_rank',
'item__category__position',
'item__category',
'item__position',
'item__variation__position',
'quota__name',
),
'subevent': 'subevent__date_from',
'-subevent': '-subevent__date_from',
'-item': (
'-seat__sorting_rank',
'-item__category__position',
'-item__category',
'-item__position',
'-item__variation__position',
'-quota__name',
)
}
status = forms.ChoiceField(
label=_('Status'),
@@ -979,7 +1013,15 @@ class VoucherFilterForm(FilterForm):
qs = qs.filter(subevent_id=fdata.get('subevent').pk)
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
ob = self.orders[fdata.get('ordering')]
if isinstance(ob, dict):
ob = dict(ob)
o = ob.pop('_order')
qs = qs.annotate(**ob).order_by(o)
elif isinstance(ob, (list, tuple)):
qs = qs.order_by(*ob)
else:
qs = qs.order_by(ob)
return qs

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Max
@@ -77,6 +79,14 @@ class QuestionForm(I18nModelForm):
dep = dep.dependency_question
return val
def clean_ask_during_checkin(self):
val = self.cleaned_data.get('ask_during_checkin')
if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
return val
def clean(self):
d = super().clean()
if d.get('dependency_question') and not d.get('dependency_values'):
@@ -415,7 +425,7 @@ class ItemUpdateForm(I18nModelForm):
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('Quota')
'data-placeholder': _('Shown independently of other products')
}
)
self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices
@@ -679,6 +689,10 @@ class ItemBundleForm(I18nModelForm):
def clean(self):
d = super().clean()
if not self.cleaned_data['designated_price']:
d['designated_price'] = Decimal('0.00')
self.instance.designated_price = Decimal('0.00')
if 'itemvar' in self.cleaned_data:
if '-' in self.cleaned_data['itemvar']:
itemid, varid = self.cleaned_data['itemvar'].split('-')

View File

@@ -0,0 +1,53 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from pretix.base.services.orderimport import get_all_columns
class ProcessForm(forms.Form):
orders = forms.ChoiceField(
label=_('Import mode'),
choices=(
('many', _('Create a separate order for each line')),
('one', _('Create one order with one position per line')),
)
)
status = forms.ChoiceField(
label=_('Order status'),
choices=(
('paid', _('Create orders as fully paid')),
('pending', _('Create orders as pending and still require payment')),
)
)
testmode = forms.BooleanField(
label=_('Create orders as test mode orders'),
required=False
)
def __init__(self, *args, **kwargs):
headers = kwargs.pop('headers')
initital = kwargs.pop('initial', {})
self.event = kwargs.pop('event')
initital['testmode'] = self.event.testmode
kwargs['initial'] = initital
super().__init__(*args, **kwargs)
header_choices = [
('csv:{}'.format(h), _('CSV column: "{name}"').format(name=h)) for h in headers
]
for c in get_all_columns(self.event):
choices = []
if c.default_value:
choices.append((c.default_value, c.default_label))
choices += header_choices
for k, v in c.static_choices():
choices.append(('static:{}'.format(k), v))
self.fields[c.identifier] = forms.ChoiceField(
label=str(c.verbose_name),
choices=choices,
widget=forms.Select(
attrs={'data-static': 'true'}
)
)

View File

@@ -11,9 +11,7 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
InvoiceAddress, ItemAddOn, Order, OrderPosition, Seat,
)
from pretix.base.models import InvoiceAddress, ItemAddOn, Order, OrderPosition
from pretix.base.models.event import SubEvent
from pretix.base.services.pricing import get_price
from pretix.control.forms.widgets import Select2
@@ -204,11 +202,10 @@ class OrderPositionAddForm(forms.Form):
required=False,
label=_('Add-on to'),
)
seat = forms.ModelChoiceField(
Seat.objects.none(),
seat = forms.CharField(
required=False,
label=_('Seat'),
empty_label=_('General admission')
widget=forms.TextInput(attrs={'placeholder': _('General admission'), 'data-seat-guid-field': 'true'}),
label=_('Seat')
)
price = forms.DecimalField(
required=False,
@@ -255,19 +252,6 @@ class OrderPositionAddForm(forms.Form):
else:
del self.fields['addon_to']
self.fields['seat'].queryset = order.event.seats.all()
self.fields['seat'].widget = Select2(
attrs={
'data-model-select2': 'seat',
'data-select2-url': reverse('control:event.seats.select2', kwargs={
'event': order.event.slug,
'organizer': order.event.organizer.slug,
}),
'data-placeholder': _('General admission')
}
)
self.fields['seat'].widget.choices = self.fields['seat'].choices
if order.event.has_subevents:
self.fields['subevent'].queryset = order.event.subevents.all()
self.fields['subevent'].widget = Select2(
@@ -318,10 +302,9 @@ class OrderPositionChangeForm(forms.Form):
required=False,
empty_label=_('(Unchanged)')
)
seat = forms.ModelChoiceField(
Seat.objects.none(),
seat = forms.CharField(
required=False,
empty_label=_('(Unchanged)')
widget=forms.TextInput(attrs={'placeholder': _('(Unchanged)'), 'data-seat-guid-field': 'true'})
)
price = forms.DecimalField(
required=False,
@@ -366,20 +349,7 @@ class OrderPositionChangeForm(forms.Form):
else:
del self.fields['subevent']
if instance.seat:
self.fields['seat'].queryset = instance.order.event.seats.all()
self.fields['seat'].widget = Select2(
attrs={
'data-model-select2': 'seat',
'data-select2-url': reverse('control:event.seats.select2', kwargs={
'event': instance.order.event.slug,
'organizer': instance.order.event.organizer.slug,
}),
'data-placeholder': _('(Unchanged)')
}
)
self.fields['seat'].widget.choices = self.fields['seat'].choices
else:
if not instance.seat:
del self.fields['seat']
choices = [

View File

@@ -211,6 +211,15 @@ class OrganizerSettingsForm(SettingsForm):
widget=I18nTextarea,
help_text=_('Not displayed anywhere by default, but if you want to, you can use this e.g. in ticket templates.')
)
event_team_provisioning = forms.BooleanField(
label=_('Allow creating a new team during event creation'),
help_text=_('Users that do not have access to all events under this organizer, must select one of their teams '
'to have access to the created event. This setting allows users to create an event-specified team'
' on-the-fly, even when they do not have \"Can change teams and permissions\" permission.'),
required=False,
)
primary_color = forms.CharField(
label=_("Primary color"),
required=False,
@@ -293,6 +302,12 @@ class OrganizerSettingsForm(SettingsForm):
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
'We recommend a size of at least 200x200px to accomodate most devices.')
)
giftcard_length = forms.IntegerField(
label=_('Length of gift card codes'),
help_text=_('The system generates by default {}-character long gift card codes. However, if a different length '
'is required, it can be set here.'.format(settings.ENTROPY['giftcard_secret'])),
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -1,7 +1,9 @@
from datetime import timedelta
from urllib.parse import urlencode
from django import forms
from django.forms import formset_factory
from django.urls import reverse
from django.utils.dates import MONTHS, WEEKDAYS
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -171,6 +173,12 @@ class SubEventMetaValueForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.fields['value'].required = False
self.fields['value'].widget.attrs['placeholder'] = self.default or self.property.default
self.fields['value'].widget.attrs['data-typeahead-url'] = (
reverse('control:events.meta.typeahead') + '?' + urlencode({
'property': self.property.name,
'organizer': self.property.organizer.slug,
})
)
class Meta:
model = SubEventMetaValue

View File

@@ -38,7 +38,7 @@ class VoucherForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
]
field_classes = {
'valid_until': SplitDateTimeField,
@@ -109,16 +109,26 @@ class VoucherForm(I18nModelForm):
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
}),
'data-placeholder': ''
'data-placeholder': _('All products')
}
)
self.fields['itemvar'].required = False
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
self.fields['itemvar'].required = True
if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
self.fields['seat'] = forms.CharField(
label=_("Specific seat ID"),
max_length=255,
required=False,
widget=forms.TextInput(attrs={'data-seat-guid-field': '1'}),
initial=self.instance.seat.seat_guid if self.instance.seat else '',
help_text=str(self.instance.seat) if self.instance.seat else '',
)
def clean(self):
data = super().clean()
if not self._errors:
if not self._errors and self.data.get('itemvar'):
try:
itemid = quotaid = None
iv = self.data.get('itemvar', '')
@@ -152,7 +162,9 @@ class VoucherForm(I18nModelForm):
Voucher.clean_item_properties(
data, self.instance.event,
self.instance.quota, self.instance.item, self.instance.variation
self.instance.quota, self.instance.item, self.instance.variation,
seats_given=data.get('seat') or data.get('seats'),
block_quota=data.get('block_quota')
)
if not self.instance.show_hidden_items and (
(self.instance.quota and all(i.hide_without_voucher for i in self.instance.quota.items.all()))
@@ -179,6 +191,11 @@ class VoucherForm(I18nModelForm):
self.instance.quota, self.instance.item, self.instance.variation
)
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
if 'seat' in self.fields and data.get('seat'):
self.instance.seat = Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
)
self.instance.item = self.instance.seat.product
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
@@ -251,7 +268,7 @@ class VoucherBulkForm(VoucherForm):
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
]
field_classes = {
'valid_until': SplitDateTimeField,
@@ -271,6 +288,13 @@ class VoucherBulkForm(VoucherForm):
super().__init__(*args, **kwargs)
self._set_field_placeholders('send_subject', ['event', 'name'])
self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'])
if 'seat' in self.fields:
self.fields['seats'] = forms.CharField(
label=_("Specific seat IDs"),
required=False,
widget=forms.Textarea(attrs={'data-seat-guid-field': '1'}),
initial=self.instance.seat.seat_guid if self.instance.seat else '',
)
def clean_send_recipients(self):
raw = self.cleaned_data['send_recipients']
@@ -296,7 +320,7 @@ class VoucherBulkForm(VoucherForm):
try:
res.append(self.Recipient(
name=row.get('name', ''),
email=row['email'],
email=row['email'].strip(),
number=int(row.get('number', 1)),
tag=row.get('tag', None)
))
@@ -309,7 +333,7 @@ class VoucherBulkForm(VoucherForm):
except ValidationError as err:
raise ValidationError(_('{value} is not a valid email address.').format(value=e.strip())) from err
else:
res.append(self.Recipient(email=e, number=1, tag=None, name=''))
res.append(self.Recipient(email=e.strip(), number=1, tag=None, name=''))
return res
def clean(self):
@@ -331,6 +355,20 @@ class VoucherBulkForm(VoucherForm):
if code_len != recp_len:
raise ValidationError(_('You generated {codes} vouchers, but entered recipients for {recp} vouchers.').format(codes=code_len, recp=recp_len))
if data.get('seats'):
seatids = [s.strip() for s in data.get('seats').strip().split("\n") if s]
if len(seatids) != len(data.get('codes')):
raise ValidationError(_('You need to specify as many seats as voucher codes.'))
data['seats'] = []
for s in seatids:
data['seat'] = s
data['seats'].append(Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.instance.event, None
))
self.instance.seat = data['seats'][0] # Trick model-level validation
else:
data['seats'] = []
return data
def save(self, event, *args, **kwargs):
@@ -339,6 +377,11 @@ class VoucherBulkForm(VoucherForm):
obj = modelcopy(self.instance)
obj.event = event
obj.code = code
try:
obj.seat = self.cleaned_data['seats'].pop()
obj.item = obj.seat.product
except IndexError:
pass
data = dict(self.cleaned_data)
data['code'] = code
data['bulk'] = True

View File

@@ -178,6 +178,7 @@ def _display_checkin(event, logentry):
@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
plains = {
'pretix.object.cloned': _('This object has been created by cloning.'),
'pretix.event.comment': _('The event\'s internal comment has been updated.'),
'pretix.event.order.modified': _('The order details have been changed.'),
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
@@ -225,6 +226,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.email.resend': _('An email with a link to the order detail page has been resent to the user.'),
'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'),
'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'),
'pretix.event.order.payment.canceled.failed': _('Cancelling payment {local_id} has failed.'),
'pretix.event.order.payment.started': _('Payment {local_id} has been started.'),
'pretix.event.order.payment.failed': _('Payment {local_id} has failed.'),
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
@@ -323,6 +325,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.device.initialized': _('The device has been initialized.'),
'pretix.device.keyroll': _('The access token of the device has been regenerated.'),
'pretix.device.updated': _('The device has notified the server of an hardware or software update.'),
'pretix.giftcards.created': _('The gift card has been created.'),
'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'),
}
data = json.loads(logentry.data)

View File

@@ -169,6 +169,57 @@ def get_event_navigation(request: HttpRequest):
})
if 'can_view_orders' in request.eventpermset:
children = [
{
'label': _('All orders'),
'url': reverse('control:event.orders', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name in ('event.orders', 'event.order') or "event.order." in url.url_name,
},
{
'label': _('Overview'),
'url': reverse('control:event.orders.overview', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.overview' in url.url_name,
},
{
'label': _('Refunds'),
'url': reverse('control:event.orders.refunds', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.refunds' in url.url_name,
},
{
'label': _('Export'),
'url': reverse('control:event.orders.export', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.export' in url.url_name,
},
{
'label': _('Waiting list'),
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.waitinglist' in url.url_name,
},
]
if 'can_change_orders' in request.eventpermset:
children.append({
'label': _('Import'),
'url': reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.import' in url.url_name,
})
nav.append({
'label': _('Orders'),
'url': reverse('control:event.orders', kwargs={
@@ -177,48 +228,7 @@ def get_event_navigation(request: HttpRequest):
}),
'active': False,
'icon': 'shopping-cart',
'children': [
{
'label': _('All orders'),
'url': reverse('control:event.orders', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name in ('event.orders', 'event.order') or "event.order." in url.url_name,
},
{
'label': _('Overview'),
'url': reverse('control:event.orders.overview', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.overview' in url.url_name,
},
{
'label': _('Refunds'),
'url': reverse('control:event.orders.refunds', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.refunds' in url.url_name,
},
{
'label': _('Export'),
'url': reverse('control:event.orders.export', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.export' in url.url_name,
},
{
'label': _('Waiting list'),
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.waitinglist' in url.url_name,
},
]
'children': children
})
if 'can_view_vouchers' in request.eventpermset:

View File

@@ -301,3 +301,14 @@ oauth_application_registered = Signal(
"""
This signal will be called whenever a user registers a new OAuth application.
"""
order_search_filter_q = Signal(
providing_args=["query"]
)
"""
This signal will be called whenever a free-text order search is performed. You are expected to return one
Q object that will be OR-ed with existing search queries. As order search exists on a global level as well,
this is not an Event signal and will be called even if your plugin is not active. ``sender`` will contain the
event if the search is performed within an event, and ``None`` otherwise. The search query will be passed as
``query``.
"""

View File

@@ -22,9 +22,16 @@
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">
{% if backend.login_form_fields %}
<button type="submit" class="btn btn-primary btn-block">
{% trans "Log in" %}
</button>
{% endif %}
{% if backend.url %}
<a href="{{ backend.url }}" class="btn btn-primary btn-block">
{{ backend.verbose_name }}
</a>
{% endif %}
{% if backend.identifier == "native" %}
{% if can_reset %}
<a href="{% url "control:auth.forgot" %}" class="btn btn-link btn-block">

View File

@@ -59,6 +59,7 @@
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
<script type="text/javascript" src="{% static "lightbox/js/lightbox.min.js" %}"></script>
<script type="text/javascript" src="{% static "are-you-sure/jquery.are-you-sure.js" %}"></script>
{% endcompress %}
{{ html_head|safe }}

View File

@@ -66,6 +66,10 @@
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Item" %} <a href="?{% url_replace request 'ordering' '-item'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'item'%}"><i class="fa fa-caret-up"></i></a></th>
{% if seats %}
<th>{% trans "Seat" %} <a href="?{% url_replace request 'ordering' '-seat'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'seat'%}"><i class="fa fa-caret-up"></i></a></th>
{% endif %}
<th>{% trans "Email" %} <a href="?{% url_replace request 'ordering' '-email'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Name" %} <a href="?{% url_replace request 'ordering' '-name'%}"><i class="fa fa-caret-down"></i></a>
@@ -95,6 +99,9 @@
{% endif %}
</td>
<td>{{ e.item }}{% if e.variation %} {{ e.variation }}{% endif %}</td>
{% if seats %}
<td>{{ e.seat }}</td>
{% endif %}
<td>
{% if e.addon_to and e.addon_to.attendee_email %}
{{ e.addon_to.attendee_email }}

View File

@@ -24,12 +24,6 @@
{% endblocktrans %}
</p>
{% bootstrap_field form.slug layout="inline" %}
<p>
{% blocktrans trimmed with slug=request.event.slug %}
Also, to make sure it's really you, please enter your user password here:
{% endblocktrans %}
</p>
{% bootstrap_field form.user_pw layout="inline" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-danger btn-save">

View File

@@ -42,6 +42,7 @@
<legend>{% trans "Invoice customization" %}</legend>
{% bootstrap_field form.invoice_renderer layout="control" %}
{% bootstrap_field form.invoice_attendee_name layout="control" %}
{% bootstrap_field form.invoice_include_expire_date layout="control" %}
{% bootstrap_field form.invoice_introductory_text layout="control" %}
{% bootstrap_field form.invoice_additional_text layout="control" %}
{% bootstrap_field form.invoice_footer_text layout="control" %}

View File

@@ -28,6 +28,12 @@
</span>
{% endif %}
</td>
<td class="iconcol">
{% for channel in provider.sales_channels %}
<span class="fa fa-{{ channel.icon }} text-muted"
data-toggle="tooltip" title="{% trans channel.verbose_name %}"></span>
{% endfor %}
</td>
<td class="text-right flip">
<a href="{% url 'control:event.settings.payment.provider' event=request.event.slug organizer=request.organizer.slug provider=provider.identifier %}"
class="btn btn-default">

View File

@@ -92,10 +92,12 @@
{% bootstrap_field sform.presale_has_ended_text layout="control" %}
{% bootstrap_field sform.voucher_explanation_text layout="control" %}
{% bootstrap_field sform.confirm_text layout="control" %}
{% bootstrap_field sform.checkout_email_helptext layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Shop design" %}</legend>
{% bootstrap_field sform.logo_image layout="control" %}
{% bootstrap_field sform.og_image layout="control" %}
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" %}
{% bootstrap_field sform.primary_color layout="control" %}

View File

@@ -76,4 +76,11 @@
{% bootstrap_field form.presale_end layout="control" %}
</fieldset>
{% endif %}
{% if form.team %}
<fieldset>
<legend>{% trans "Team" %}</legend>
{% bootstrap_field form.team layout="control" %}
</fieldset>
{% endif %}
{% endblock %}

View File

@@ -24,6 +24,15 @@
{% endblocktrans %}
</div>
{% endif %}
{% if object.hidden_if_available and object.hidden_if_available.availability.0 == 100 %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
This product is currently not being shown since you configured below that it should only be visible
if a certain other quota is already sold out.
{% endblocktrans %}
</div>
{% endif %}
{% block inside %}
{% endblock %}
{% endblock %}

View File

@@ -35,12 +35,12 @@
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.max_per_order layout="control" %}
{% bootstrap_field form.min_per_order layout="control" %}
{% bootstrap_field form.hidden_if_available layout="control" %}
{% bootstrap_field form.require_voucher layout="control" %}
{% bootstrap_field form.hide_without_voucher layout="control" %}
{% bootstrap_field form.require_bundling layout="control" %}
{% bootstrap_field form.allow_cancel layout="control" %}
{% bootstrap_field form.allow_waitinglist layout="control" %}
{% bootstrap_field form.hidden_if_available layout="control" %}
</fieldset>
{% for v in formsets.values %}
<fieldset>

View File

@@ -73,13 +73,22 @@
</h3>
</div>
<div class="panel-body">
<div class="form-order-change" data-pricecalc-endpoint="{% url "api-v1:orderposition-price_calc" organizer=order.event.organizer.slug event=order.event.slug pk=position.pk %}" {% if position.subevent %}data-subevent="{{ position.subevent.id }}{% endif %}">
<div class="form-order-change" data-pricecalc-endpoint="{% url "api-v1:orderposition-price_calc" organizer=order.event.organizer.slug event=order.event.slug pk=position.pk %}" {% if position.subevent %}data-subevent="{{ position.subevent.id }}"{% endif %} data-position="{{ position.pk }}">
{% bootstrap_form_errors position.form %}
{% if position.custom_error %}
<div class="alert alert-danger">
{{ position.custom_error }}
</div>
{% endif %}
{% if position.voucher and position.voucher.budget %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
This position has been created with a voucher with a limited budget. If you
change the price or item, the discount will still be calculated from the original
price at the time of purchase.
{% endblocktrans %}
</div>
{% endif %}
<div class="row">
<div class="col-sm-5 col-sm-offset-3">
<strong>{% trans "Current value" %}</strong>
@@ -181,7 +190,7 @@
{% bootstrap_formset_errors add_formset %}
<div data-formset-body>
{% for add_form in add_formset %}
<div class="panel panel-default items" data-formset-form>
<div class="panel panel-default items" data-formset-form data-subevent="0">
<div class="panel-heading">
<h3 class="panel-title">
<button type="button" class="btn btn-danger btn-xs pull-right flip"
@@ -219,7 +228,7 @@
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default items" data-formset-form>
<div class="panel panel-default items" data-formset-form data-subevent="0">
<div class="panel-heading">
<h3 class="panel-title">
<button type="button" class="btn btn-danger btn-xs pull-right flip"

View File

@@ -266,14 +266,15 @@
<br />
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668" class="svg-icon">
<path
style="fill:black"
d="m 1.9592032,1.8522629e-4 c -0.21468,0 -0.38861,0.17394000371 -0.38861,0.38861000371 0,0.21466 0.17393,0.38861 0.38861,0.38861 0.21468,0 0.3886001,-0.17395 0.3886001,-0.38861 0,-0.21467 -0.1739201,-0.38861000371 -0.3886001,-0.38861000371 z m 0.1049,0.84543000371 c -0.20823,-0.0326 -0.44367,0.12499 -0.39998,0.40462997 l 0.20361,1.01854 c 0.0306,0.15316 0.15301,0.28732 0.3483,0.28732 h 0.8376701 v 0.92708 c 0,0.29313 0.41187,0.29447 0.41187,0.005 v -1.19115 c 0,-0.14168 -0.0995,-0.29507 -0.29094,-0.29507 l -0.65578,-10e-4 -0.1757,-0.87644 C 2.3042533,0.95300523 2.1890432,0.86500523 2.0641032,0.84547523 Z m -0.58549,0.44906997 c -0.0946,-0.0134 -0.20202,0.0625 -0.17829,0.19172 l 0.18759,0.91054 c 0.0763,0.33956 0.36802,0.55914 0.66042,0.55914 h 0.6015201 c 0.21356,0 0.21448,-0.32143 -0.003,-0.32143 H 2.1954632 c -0.19911,0 -0.36364,-0.11898 -0.41341,-0.34107 l -0.17777,-0.87126 c -0.0165,-0.0794 -0.0688,-0.11963 -0.12557,-0.12764 z"/>
</svg>
{{ line.seat }}
{% endif %}
{% if line.voucher %}
<br/><span class="fa fa-tags"></span> {% trans "Voucher code used:" %}
<a href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
<a
{% if line.price_before_voucher|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with price=line.price_before_voucher|money:request.event.currency %}Original price: {{ price }}{% endblocktrans %}"{% endif %}
href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
{{ line.voucher.code }}
</a>
{% endif %}
@@ -646,12 +647,28 @@
{% endif %}
</td>
</tr>
{% if staff_session %}
{% if r.html_info %}
<tr>
<td colspan="1"></td>
<td colspan="7">
{{ r.html_info|safe }}
{% if staff_session %}
<p>
<a href="" class="btn btn-default btn-xs" data-expandrefund
data-id="{{ r.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>
</p>
{% endif %}
</td>
</tr>
{% elif staff_session %}
<tr>
<td colspan="1"></td>
<td colspan="7">
<a href="" class="btn btn-default btn-xs" data-expandrefund
data-id="{{ r.pk }}">
data-id="{{ r.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>

View File

@@ -83,6 +83,27 @@
value="" title="" class="form-control">
</td>
</tr>
<tr>
<td></td>
<td></td>
<td>
<strong>{% trans "Create a new gift card" %}</strong>
</td>
<td></td>
<td>
<div class="input-group">
<input type="text" name="refund-new-giftcard"
title="" class="form-control" value="{{ 0|floatformat:2 }}">
<span class="input-group-addon">
{{ request.event.currency }}
</span>
</div>
<div class="text-muted">
{% trans "The gift card can be used to buy tickets for all events of this organizer." %}
</div>
</td>
</tr>
<tr>
<td></td>

View File

@@ -0,0 +1,61 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% load getitem %}
{% load bootstrap3 %}
{% block title %}{% trans "Import attendees" %}{% endblock %}
{% block content %}
<h1>{% trans "Import attendees" %}</h1>
<form action="" method="post" class="form-horizontal" data-asynctask data-asynctask-long>
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Data preview" %}</h3>
</div>
<div class="table-responsive panel-body">
<table class="table table-condensed">
<thead>
<tr>
{% for fn in parsed.fieldnames %}
<th>{{ fn }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for r in sample_rows %}
<tr>
{% for fn in parsed.fieldnames %}
<td>{{ r|getitem:fn }}</td>
{% endfor %}
</tr>
{% endfor %}
<tr>
<td class="text-center" colspan="{{ parsed.fieldnames|length }}">
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Import settings" %}</h3>
</div>
<div class="panel-body">
{% bootstrap_form_errors form %}
{% bootstrap_form form layout="horizontal" %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
The import will be performed regardless of your quotas, so it will be possible to overbook your event using this option.
{% endblocktrans %}
</div>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Perform import" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Import attendees" %}{% endblock %}
{% block content %}
<h1>{% trans "Import attendees" %}</h1>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
</div>
<div class="panel-body">
<form action="" method="post" enctype="multipart/form-data" class="form-inline">
{% csrf_token %}
<p>
{% blocktrans trimmed %}
The uploaded file should be a CSV file with a header row. You will be able to assign the
meanings of the different columns in the next step.
{% endblocktrans %}
</p>
<div class="form-group">
<label for="file">{% trans "Import file" %}: </label> <input id="file" type="file" name="file"/>
</div>
<div class="clearfix"></div>
<button class="btn btn-primary pull-right flip" type="submit">
<span class="icon icon-upload"></span> {% trans "Start import" %}
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -2,6 +2,10 @@
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% block custom_header %}
{{ block.super }}
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
{% endblock %}
{% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %}
<h1>
@@ -27,6 +31,8 @@
{% bootstrap_field form.domain layout="control" %}
{% endif %}
{% bootstrap_field sform.organizer_info_text layout="control" %}
{% bootstrap_field sform.event_team_provisioning layout="control" %}
{% bootstrap_field sform.giftcard_length layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Organizer page" %}</legend>

View File

@@ -11,81 +11,95 @@
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</h1>
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Details" %}
</h3>
</div>
<div class="panel-body">
<dl class="dl-horizontal">
<dt>{% trans "Gift card code" %}</dt>
<dd>{{ card.secret }}</dd>
<dt>{% trans "Creation date" %}</dt>
<dd>{{ card.issuance|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "Current value" %}</dt>
<dd>{{ card.value|money:card.currency }}</dd>
<dt>{% trans "Currency" %}</dt>
<dd>{{ card.currency }}</dd>
{% if card.issued_in %}
<dt>{% trans "Issued through sale" %}</dt>
<dd>
<a href="{% url "control:event.order" event=card.issued_in.order.event.slug organizer=card.issued_in.order.event.organizer.slug code=card.issued_in.order.code %}">
{{ card.issued_in.order.full_code }}</a>-{{ card.issued_in.positionid }}
</dd>
{% endif %}
</dl>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Transactions" %}
</h3>
</div>
<table class="panel-body table">
<thead>
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "Order" %}</th>
<th class="text-right">{% trans "Value" %}</th>
</tr>
</thead>
<tbody>
{% for t in card.transactions.all %}
<tr>
<td>{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>
{% if t.order %}
<a href="{% url "control:event.order" event=t.order.event.slug organizer=t.order.event.organizer.slug code=t.order.code %}">
{{ t.order.full_code }}
</a>
{% else %}
<em>{% trans "Manual transaction" %}</em>
<div class="row">
<div class="col-md-10 col-xs-12">
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Details" %}
</h3>
</div>
<div class="panel-body">
<dl class="dl-horizontal">
<dt>{% trans "Gift card code" %}</dt>
<dd>{{ card.secret }}</dd>
<dt>{% trans "Creation date" %}</dt>
<dd>{{ card.issuance|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "Current value" %}</dt>
<dd>{{ card.value|money:card.currency }}</dd>
<dt>{% trans "Currency" %}</dt>
<dd>{{ card.currency }}</dd>
{% if card.issued_in %}
<dt>{% trans "Issued through sale" %}</dt>
<dd>
<a href="{% url "control:event.order" event=card.issued_in.order.event.slug organizer=card.issued_in.order.event.organizer.slug code=card.issued_in.order.code %}">
{{ card.issued_in.order.full_code }}</a>-{{ card.issued_in.positionid }}
</dd>
{% endif %}
</td>
<td class="text-right">
{{ t.value|money:card.currency }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td></td>
<td></td>
<td class="text-right">
<form class="helper-display-inline form-inline" method="post" action="">
{% csrf_token %}
<input type="text" class="form-control input-sm" placeholder="{% trans "Value" %}" name="value">
<button class="btn btn-primary">
<span class="fa fa-plus"></span>
</button>
</form>
</td>
</dl>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Transactions" %}
</h3>
</div>
<table class="panel-body table">
<thead>
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "Order" %}</th>
<th class="text-right">{% trans "Value" %}</th>
</tr>
</thead>
<tbody>
{% for t in card.transactions.all %}
<tr>
<td>{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>
{% if t.order %}
<a href="{% url "control:event.order" event=t.order.event.slug organizer=t.order.event.organizer.slug code=t.order.code %}">
{{ t.order.full_code }}
</a>
{% else %}
<em>{% trans "Manual transaction" %}</em>
{% endif %}
</td>
<td class="text-right">
{{ t.value|money:card.currency }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td></td>
<td></td>
<td class="text-right">
<form class="helper-display-inline form-inline" method="post" action="">
{% csrf_token %}
<input type="text" class="form-control input-sm" placeholder="{% trans "Value" %}" name="value">
<button class="btn btn-primary">
<span class="fa fa-plus"></span>
</button>
</form>
</td>
</tr>
</tfoot>
</table>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="col-md-2 col-xs-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Gift card history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=card %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -198,11 +198,24 @@
</div>
<div class="col-sm-12 help-inline">
<p>
After you changed the page size, you need to create a new empty background. If you
want to use a custom background, it already needs to have the correct size.
{% blocktrans trimmed %}
After you changed the page size, you need to create a new empty background. If you
want to use a custom background, it already needs to have the correct size.
{% endblocktrans %}
</p>
</div>
</div>
<div class="row control-group pdf-info">
<div class="col-sm-12">
<label>{% trans "Prefered language" %}</label><br>
<select class="form-control" id="pdf-info-locale">
<option value="">{% trans "Order locale" %}</option>
{% for l in locales %}
<option value="{{ l.0 }}">{{ l.1 }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row control-group position">
<div class="col-sm-6">
<label>{% trans "x (mm)" %}</label><br>
@@ -251,6 +264,12 @@
<span class="fa fa-italic"></span>
</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default toggling" data-action="downward"
data-toggle="tooltip" title="{% trans "Flow multiple lines downward from specified position" %}">
<span class="fa fa-caret-square-o-down"></span>
</button>
</div>
</div>
</div>
</div>
@@ -293,11 +312,16 @@
</div>
</div>
<div class="row control-group text">
<div class="col-sm-12">
<div class="col-sm-6">
<label>{% trans "Width (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-textwidth">
</div>
<div class="col-sm-6">
<label>{% trans "Rotation (°)" %}</label><br>
<input type="number" value="0" class="input-block-level form-control" step="0.1"
id="toolbox-textrotation">
</div>
</div>
<div class="row control-group poweredby">
<div class="col-sm-12">

View File

@@ -40,12 +40,12 @@
<fieldset>
<legend>{% trans "Step 3: Confirm deletion" %}</legend>
<p>
{% blocktrans trimmed with event=request.event.name %}
{% blocktrans trimmed with event=request.event.name slug=request.event.slug %}
Please re-check that you are fully certain that you want to delete the selected categories of data from the event <strong>{{ event }}</strong>.
In this case, please enter your user password here:
To confirm you really want this, please type out the event's short name ("{{ slug }}") here:
{% endblocktrans %}
</p>
<input type="password" class="form-control" name="password" required placeholder="{% trans "Your password" %}">
<input type="text" class="form-control" name="slug" required placeholder="{% trans "Event short name" %}">
</fieldset>
<input type="hidden" name="file" value="{{ file.pk }}">
<div class="form-group submit-group">

View File

@@ -8,7 +8,11 @@
{% csrf_token %}
<h3>{% trans "Welcome back!" %}</h3>
<p>
{% trans "We just want to make sure it's really you. Please re-enter your password to continue." %}
{% if form.backend.url %}
{% blocktrans trimmed with login_provider=form.backend.verbose_name %}We just want to make sure it's really you. Please re-authenticate with '{{ login_provider }}'.{% endblocktrans %}
{% else %}
{% trans "We just want to make sure it's really you. Please re-enter your password to continue." %}
{% endif %}
</p>
{% bootstrap_form form %}
<input class="form-control" id="webauthn-response" name="webauthn"
@@ -23,9 +27,15 @@
</small></p>
{% endif %}
<div class="form-group text-right flip">
<button type="submit" class="btn btn-primary btn-block">
{% trans "Continue" %}
</button>
{% if form.backend.url %}
<a href="{{ form.backend.url }}" class="btn btn-primary btn-block">
{% trans "Continue" %}
</a>
{% else %}
<button type="submit" class="btn btn-primary btn-block">
{% trans "Continue" %}
</button>
{% endif %}
<a href="{% url "control:auth.logout" %}" class="btn btn-link btn-block">
{% trans "Log in as someone else" %}
</a>

View File

@@ -65,11 +65,15 @@
{% if form.subevent %}
{% bootstrap_field form.subevent layout="control" %}
{% endif %}
{% if "seats" in form.fields %}
{% bootstrap_field form.seats layout="control" %}
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.block_quota layout="control" %}
{% bootstrap_field form.allow_ignore_quota layout="control" %}
{% bootstrap_field form.budget addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.tag layout="control" %}
{% bootstrap_field form.comment layout="control" %}
{% bootstrap_field form.show_hidden_items layout="control" %}

View File

@@ -67,11 +67,15 @@
{% if form.subevent %}
{% bootstrap_field form.subevent layout="control" %}
{% endif %}
{% if "seat" in form.fields %}
{% bootstrap_field form.seat layout="control" %}
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.block_quota layout="control" %}
{% bootstrap_field form.allow_ignore_quota layout="control" %}
{% bootstrap_field form.budget addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.tag layout="control" %}
{% bootstrap_field form.comment layout="control" %}
{% bootstrap_field form.show_hidden_items layout="control" %}

View File

@@ -2,6 +2,7 @@
{% load i18n %}
{% load bootstrap3 %}
{% load urlreplace %}
{% load money %}
{% block title %}{% trans "Vouchers" %}{% endblock %}
{% block content %}
<h1>{% trans "Vouchers" %}</h1>
@@ -95,13 +96,37 @@
<input type="checkbox" data-toggle-table />
{% endif %}
</th>
<th>{% trans "Voucher code" %}</th>
<th>{% trans "Redemptions" %}</th>
<th>{% trans "Expiry" %}</th>
<th>{% trans "Tag" %}</th>
<th>{% trans "Product" %}</th>
<th>
{% trans "Voucher code" %}
<a href="?{% url_replace request 'ordering' '-code' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Redemptions" %}
<a href="?{% url_replace request 'ordering' '-redeemed' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'redeemed' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Expiry" %}
<a href="?{% url_replace request 'ordering' '-valid_until' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'valid_until' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Tag" %}
<a href="?{% url_replace request 'ordering' '-tag' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'tag' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Product" %}
<a href="?{% url_replace request 'ordering' '-item' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'item' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}</th>
<th>
{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'ordering' '-subevent' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'subevent' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th></th>
</tr>
@@ -115,9 +140,19 @@
{% endif %}
</td>
<td>
{% if not v.is_active %}<del>{% endif %}
<strong><a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}">{{ v.code }}</a></strong>
{% if not v.is_active %}</del>{% endif %}
</td>
<td>
{{ v.redeemed }} / {{ v.max_usages }}
{% if v.budget|default_if_none:"NONE" != "NONE" %}
<br>
<small class="text-muted">
{{ v.budget_used_orders|money:request.event.currency }} / {{ v.budget|money:request.event.currency }}
</small>
{% endif %}
</td>
<td>{{ v.redeemed }} / {{ v.max_usages }}</td>
<td>{{ v.valid_until|date }}</td>
<td>
{{ v.tag }}
@@ -133,6 +168,7 @@
Any product in quota "{{ quota }}"
{% endblocktrans %}
{% endif %}
{% if v.seat %}<br><small class="text-muted">{{ v.seat }}</small>{% endif %}
</td>
{% if request.event.has_subevents %}
<td>{{ v.subevent.name }} {{ v.subevent.get_date_range_display }}</td>

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