Compare commits

...

167 Commits

Author SHA1 Message Date
Raphael Michel
e89aaf4059 Bump to 2.7.0 2019-05-09 16:20:40 +02:00
Raphael Michel
db270b3bf2 Update from Weblate (#1276)
Update from Weblate
2019-05-09 14:48:51 +02:00
Raphael Michel
d8b78c3a7a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3067 of 3067 strings)

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

powered by weblate
2019-05-09 12:48:23 +00:00
Raphael Michel
67c448a29e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3067 of 3067 strings)

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

powered by weblate
2019-05-09 12:48:22 +00:00
Raphael Michel
5b7906f2a1 Translated on translate.pretix.eu (German (informal))
Currently translated at 99.8% (3060 of 3067 strings)

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

powered by weblate
2019-05-09 12:45:12 +00:00
Raphael Michel
0612d42607 Translated on translate.pretix.eu (German)
Currently translated at 99.8% (3060 of 3067 strings)

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

powered by weblate
2019-05-09 12:45:12 +00:00
Raphael Michel
83f866034a Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-05-09 14:45:03 +02:00
Raphael Michel
b1fa214869 Clarify a description 2019-05-09 14:44:06 +02:00
Raphael Michel
aa53b5235a Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-05-09 14:39:58 +02:00
Raphael Michel
61a13256a0 Update from Weblate (#1269)
Update from Weblate
2019-05-09 14:39:16 +02:00
Raphael Michel
64e2336014 Added translation on translate.pretix.eu (Slovenian) 2019-05-09 11:47:16 +00:00
Bostjan Marusic
3411abd1e6 Added translation on translate.pretix.eu (Slovenian) 2019-05-09 11:47:16 +00:00
Allan Nordhøy
2a34e54fae Added translation on translate.pretix.eu (Norwegian Bokmål) 2019-05-09 11:47:16 +00:00
Alvaro Enrique Ruano
9863dc35d6 Translated on translate.pretix.eu (Spanish)
Currently translated at 99.0% (3031 of 3063 strings)

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

powered by weblate
2019-05-09 11:47:16 +00:00
Raphael Michel
690883a198 Fix #480 -- Allow plugins to specify a minimum pretix version 2019-05-09 13:46:54 +02:00
Raphael Michel
d8ded08a46 Checkin list PDF: Remove date from headline, it's in the page header now 2019-05-09 10:52:42 +02:00
Raphael Michel
4aab5daa57 Fixing import order 2019-05-09 10:22:09 +02:00
Raphael Michel
e87628c902 Ensure that we document all signals 2019-05-09 10:02:12 +02:00
Raphael Michel
3c7bf46268 Resolve requests/urllib versions 2019-05-09 10:02:12 +02:00
Raphael Michel
a1dacb1897 Remove outdated reference from docs 2019-05-09 10:02:12 +02:00
Raphael Michel
08d5626704 Simplify the future of our migration history 2019-05-09 10:02:12 +02:00
Raphael Michel
c8a1481f93 Fix #1154 -- Add country-typed questions 2019-05-09 10:02:12 +02:00
Raphael Michel
e7c4121745 Add hidden questions 2019-05-09 10:02:12 +02:00
Sohalt
35ddd8dd28 Typo (#1274) 2019-05-08 13:13:27 +02:00
Raphael Michel
e2ec6eb156 Dekodi: Change semantics of signs 2019-05-08 11:47:57 +02:00
Raphael Michel
42edc4c3aa money_filter: Ignore case of currency 2019-05-07 16:20:24 +02:00
Raphael Michel
1cb2f99f3a Tax calculation of "original prices" 2019-05-06 12:33:21 +02:00
Raphael Michel
d4146e08b1 Fix widget tests 2019-05-06 12:06:45 +02:00
Raphael Michel
79ae9b6501 Revert "updatestyles: Fix a TypeError"
This reverts commit 53053f19e4.
2019-05-06 11:43:40 +02:00
Raphael Michel
c23f71a19c Widget: Add voucher explanation text 2019-05-06 11:33:48 +02:00
Raphael Michel
53053f19e4 updatestyles: Fix a TypeError 2019-05-06 11:33:36 +02:00
Raphael Michel
a42b2d76f6 Add scalability to docs word list 2019-05-06 08:50:56 +02:00
Raphael Michel
51392f73a8 Locking optimizations 2019-05-05 17:31:08 +02:00
Raphael Michel
465a5b01b9 Offload more work to database replica 2019-05-05 17:31:08 +02:00
Raphael Michel
74a6004613 Documentation on scaling 2019-05-05 17:31:08 +02:00
Sohalt
f9fc33eba1 Fix #1266 -- Make references to plugin settings clickable links (#1268) 2019-05-02 09:18:42 +02:00
Raphael Michel
363dc74c31 Dekodi: Try to find correct PayPal ID 2019-05-02 09:12:57 +02:00
Raphael Michel
efb598e93a Update from Weblate (#1267)
Update from Weblate
2019-05-01 14:13:47 +02:00
Raphael Michel
bcfaf2801d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3063 of 3063 strings)

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

powered by weblate
2019-05-01 12:13:28 +00:00
Raphael Michel
98db417fe6 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-05-01 12:13:18 +00:00
Raphael Michel
a03ffd949e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3063 of 3063 strings)

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

powered by weblate
2019-05-01 12:12:58 +00:00
Raphael Michel
88ef46dee9 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-05-01 12:12:39 +00:00
Raphael Michel
9bc6941c14 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-05-01 14:02:11 +02:00
Raphael Michel
987da83894 Refs #1102 -- Accept order URLs in order lookup 2019-05-01 14:01:26 +02:00
Raphael Michel
d029d92a92 Fix #1102 -- "View in backend" (doesn't work with custom domains) 2019-05-01 14:01:26 +02:00
Raphael Michel
f1b07777bc Timezone indicators in the backend 2019-05-01 14:01:26 +02:00
Raphael Michel
db187a2537 Fix #1126 -- Use short datetime format on order details page 2019-05-01 14:01:26 +02:00
Raphael Michel
e9a340d9ca Refs #1128 -- Popover on disabled "add to cart" button 2019-05-01 14:01:26 +02:00
Raphael Michel
6841a30d8f Fix #1153 -- Show preview of uploaded pictures in the backend 2019-05-01 14:01:26 +02:00
Raphael Michel
30b8c0f4b9 Fix ClearableBasenameFileInput with current Django 2019-05-01 14:01:26 +02:00
Raphael Michel
3e8f32e7e3 Fix #1178 -- Invalidate ticket cache after order locale change 2019-05-01 14:01:26 +02:00
Raphael Michel
2b145e254b Fix #1211 -- Locale selection on organizer profile 2019-05-01 14:01:26 +02:00
Raphael Michel
e5c2470fde "Go to shop" for organizers 2019-05-01 14:01:26 +02:00
Raphael Michel
2da93eba26 Fix #1230 -- Stripe: Recognize canceled sources in webhook 2019-05-01 14:01:26 +02:00
Raphael Michel
788f73d842 Fix #1255 -- Approvals of free orders after last date of payments 2019-05-01 14:01:26 +02:00
Raphael Michel
d86b3a2173 Update from Weblate (#1265)
Update from Weblate
2019-04-30 09:51:49 +02:00
Tobias Sundgren
7be6046ed5 Translated on translate.pretix.eu (Swedish)
Currently translated at 100.0% (97 of 97 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
Maarten van den Berg
6b90689067 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (97 of 97 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
Maarten van den Berg
815816b9d6 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (97 of 97 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
Tobias Sundgren
3199687fe4 Translated on translate.pretix.eu (Swedish)
Currently translated at 0.8% (24 of 3060 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
Maarten van den Berg
6d8b8c6346 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3060 of 3060 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
Maarten van den Berg
8a850773f4 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3060 of 3060 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
Raphael Michel
2a10f875e4 Added translation on translate.pretix.eu (Swedish) 2019-04-30 07:51:24 +00:00
Raphael Michel
d8d2a21bda Added translation on translate.pretix.eu (Swedish) 2019-04-30 07:51:24 +00:00
oocf
18eb468d8e Translated on translate.pretix.eu (Spanish)
Currently translated at 99.0% (3028 of 3060 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
ThanosTeste
2842b0e720 Translated on translate.pretix.eu (Greek)
Currently translated at 81.4% (79 of 97 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
Chris Spy
42936a931b Translated on translate.pretix.eu (Greek)
Currently translated at 81.4% (79 of 97 strings)

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

powered by weblate
2019-04-30 07:51:24 +00:00
Raphael Michel
a6c72abe75 Change semantics of changing orders (#1260)
* Change semantics of changing orders

This basically does two things to the "Change products" view of orders and the
OrderChangeManager program API:

1) It decouples changing items or subevents from changing prices.
   OrderChangeManager.change_item() and .change_subevent() no longer
   touch the price of a position. Instead .change_price() needs to be
   called explicitly. However, a client-side JavaScript component now
   *proposes* a new price based on the changed item or subevent.

2) The user interface now exposes the possibility of doing multiple
   things at the same time, i.e. changing the item, subevent and price
   in the same operation. OrderChangeManager already allowed this
   before.

(1) is basically a consequence of (2), while (2) is a prerequesite for
e.g. the `seating` branch, where changing the subevent will always
require changing the seat.

* Add tests for price calculation API
2019-04-30 09:51:19 +02:00
Raphael Michel
df3e6f4b9a dekodi: Fix version and mandatory fields 2019-04-30 09:50:47 +02:00
Raphael Michel
8ef99ba828 Dekodi: Merchant PayPal IDs 2019-04-30 09:50:17 +02:00
Raphael Michel
e8e5f5c7bf Dekodi: Get rid of null values 2019-04-29 15:46:48 +02:00
Martin Gross
f0128429e4 Format amount in GiroCode/EPC-QR with dot instead of locale 2019-04-29 13:54:53 +02:00
Raphael Michel
cc8e5a7f83 Widget: original price for variations 2019-04-29 09:30:03 +02:00
Raphael Michel
d4d3928146 Expose is_public in subevent editor 2019-04-29 09:30:03 +02:00
Raphael Michel
cc4602c308 API Auth: Respect staff sessions 2019-04-26 16:24:13 +02:00
Raphael Michel
2bc0dd6076 Dekodi export: date filter 2019-04-26 15:22:10 +02:00
Raphael Michel
f286c5af28 Dekodi: Never encode money as strings 2019-04-25 21:07:10 +02:00
Raphael Michel
ec27ed198b Add Dekodi exporter 2019-04-25 20:36:24 +02:00
Raphael Michel
2ee0f684c5 PDF variable: price including add-ons 2019-04-25 19:34:51 +02:00
Raphael Michel
951386b32c Add subevent column to order list export 2019-04-25 15:08:22 +02:00
Raphael Michel
f498e8fafa Fix faulty test cases 2019-04-25 14:00:55 +02:00
Raphael Michel
b79947fba4 Widget: Original price for variations 2019-04-25 11:54:21 +02:00
Raphael Michel
ef600ceddb Fix invalid handling of variations with quota-level vouchers 2019-04-25 11:54:03 +02:00
Raphael Michel
13bf975dd5 Fix KeyError during form validation 2019-04-25 10:36:29 +02:00
Raphael Michel
8e56c8dcf7 Fix documentation typos 2019-04-23 17:39:09 +02:00
Raphael Michel
a42b31560c Check-in API: Fall back from attendee_name 2019-04-23 17:25:35 +02:00
Raphael Michel
e15e7a5877 Check-in API: Return 400 instead of 404 on checking in unpaid orders 2019-04-23 17:18:16 +02:00
Raphael Michel
e7384f7e85 Check-in API: require_attention and ignore_status 2019-04-23 17:06:24 +02:00
Raphael Michel
840b30c3c2 Linkify email addresses 2019-04-23 17:06:24 +02:00
Martin Gross
1adabec989 Fix test: Price override shows old price as <del> 2019-04-23 15:37:51 +02:00
Martin Gross
171bea59df Show strikethrough price when voucher is granting discount 2019-04-23 14:26:21 +02:00
Martin Gross
3c4b086992 Show strikethrough original_price when redeeming voucher 2019-04-23 11:55:31 +02:00
Raphael Michel
6a4e6e227c Fix isort issue 2019-04-23 11:19:19 +02:00
Raphael Michel
9c3abc5338 More precise log message for skipped attachments 2019-04-23 11:18:03 +02:00
Raphael Michel
91b2d7989a Update from Weblate (#1261)
Update from Weblate
2019-04-23 10:55:30 +02:00
Raphael Michel
c5a80e6daf Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (97 of 97 strings)

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

powered by weblate
2019-04-23 08:55:15 +00:00
Raphael Michel
37ce9fa9af Translated on translate.pretix.eu (German)
Currently translated at 100.0% (97 of 97 strings)

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

powered by weblate
2019-04-23 08:55:14 +00:00
Raphael Michel
64fe3d772c Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3060 of 3060 strings)

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

powered by weblate
2019-04-23 08:55:14 +00:00
Raphael Michel
5c82781fcc Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3060 of 3060 strings)

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

powered by weblate
2019-04-23 08:54:56 +00:00
Raphael Michel
0d70e3c8e3 Specify minor version of urllib3 2019-04-23 10:50:59 +02:00
Raphael Michel
85a7f0c0cc Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-04-23 10:50:48 +02:00
Raphael Michel
6d0e1097e6 Update from Weblate (#1257)
Update from Weblate
2019-04-23 10:50:10 +02:00
David100mark
c557087252 Translated on translate.pretix.eu (French)
Currently translated at 77.3% (2366 of 3059 strings)

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

powered by weblate
2019-04-23 08:49:35 +00:00
David100mark
62796cdc5f Translated on translate.pretix.eu (French)
Currently translated at 77.2% (2362 of 3059 strings)

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

powered by weblate
2019-04-23 08:49:35 +00:00
David100mark
bbe5f9bd98 Translated on translate.pretix.eu (French)
Currently translated at 76.1% (2329 of 3059 strings)

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

powered by weblate
2019-04-23 08:49:35 +00:00
Maarten van den Berg
003ccd83bf Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3059 of 3059 strings)

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

powered by weblate
2019-04-23 08:49:35 +00:00
Maarten van den Berg
f8f6dc4a51 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3059 of 3059 strings)

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

powered by weblate
2019-04-23 08:49:35 +00:00
Raphael Michel
cddf716784 Translated on translate.pretix.eu (German (informal))
Currently translated at 99.9% (3057 of 3059 strings)

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

powered by weblate
2019-04-23 08:49:35 +00:00
Raphael Michel
ee495f2777 Add property SubEvent.is_public 2019-04-23 10:46:09 +02:00
Raphael Michel
5bdc9011c1 Widget: Specific wording for mobing back to subevents 2019-04-23 10:37:25 +02:00
Raphael Michel
c6ea30ec1e Widget: Handle resize events 2019-04-23 10:35:07 +02:00
Raphael Michel
f9341b4d47 Downgrade urllib3 2019-04-23 10:15:01 +02:00
Raphael Michel
2205e57650 Fail consistently on invalid payment providers 2019-04-23 09:47:55 +02:00
Raphael Michel
ad8fdd6935 Ignore quota errors during order creation 2019-04-23 09:47:44 +02:00
Raphael Michel
02e936ee7a Fix #522 -- Do not allow any orders after the last date of payments 2019-04-23 09:46:34 +02:00
Raphael Michel
45a6923220 Refs #522 -- Do not allow to create orders after the last date of payments 2019-04-23 09:41:01 +02:00
Raphael Michel
e4417305a2 Fix updatestyles not being sent to background queue 2019-04-18 17:44:14 +02:00
Raphael Michel
bc5d0bea00 updatestyles: Prioritize future events over past ones 2019-04-18 17:27:34 +02:00
Raphael Michel
dbce9b0395 Allow error pages to be embedded in frames (to ease widget troubleshooting) 2019-04-18 17:19:42 +02:00
Martin Gross
2eb88840bd Original price for variations (#1258)
* Original price for variations

* Documentation

* API-GET

* Fix existing tests to accomodate new attribute

* Test for variation's original_price on API
2019-04-18 16:13:49 +02:00
Martin Gross
4838835b1b Remove debug-toolbar template override 2019-04-18 12:21:42 +02:00
Raphael Michel
ab452bd9e3 Fix typo 2019-04-18 09:50:07 +02:00
Raphael Michel
ae298bddb8 Make FakeRedis play nice with metrics 2019-04-18 09:17:55 +02:00
Raphael Michel
9ad4607d26 Move ticket cache invalidation to background task 2019-04-18 09:17:01 +02:00
Raphael Michel
b3684377cd Fix crash in item validation
Fixes Sentry PRETIXEU-10B
2019-04-17 15:40:25 +02:00
Raphael Michel
441badfdbd Bank transfer: Move ack field 2019-04-17 15:38:26 +02:00
Raphael Michel
0d242a0304 Fix internal error during validation
Sentry PRETIXEU-10A
2019-04-17 15:21:42 +02:00
Raphael Michel
2fac8592d4 Add modern invoice renderer 2019-04-17 15:08:58 +02:00
Raphael Michel
58b1a2f115 Fix timezone handling in widget 2019-04-17 14:42:00 +02:00
Raphael Michel
420d44e909 Fix #1170 -- E-mail address in check-in list 2019-04-17 12:12:07 +02:00
Raphael Michel
e0063fce52 Allow superusers to inspect payments and refunds 2019-04-17 10:15:14 +02:00
Raphael Michel
21ef6c7950 Update framework classifier 2019-04-17 10:07:02 +02:00
Sohalt
651f429ffb Fix #1247 -- Allow team invites to be resent (#1250)
* Fix #1247 -- Allow team invites to be resent

* Test resending invalid invites

* Fix tooltip

* Fix test

* Handle invalid types for pk parameter

* Style button
2019-04-16 16:39:31 +02:00
Raphael Michel
66dd7c448b Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-04-16 13:35:36 +02:00
Raphael Michel
e9b4205145 Fix translation of widget headlines 2019-04-16 13:35:07 +02:00
Raphael Michel
6dedea1025 Items API: Note that tax_rate is read-only 2019-04-16 13:35:07 +02:00
Raphael Michel
348ed4e909 Merge pull request #1244 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-04-16 13:34:26 +02:00
Maarten van den Berg
091b3358e4 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (96 of 96 strings)

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

powered by weblate
2019-04-16 05:00:07 +00:00
Maarten van den Berg
186e2a6b9a Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (96 of 96 strings)

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

powered by weblate
2019-04-16 05:00:06 +00:00
Maarten van den Berg
198b90972c Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3057 of 3057 strings)

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

powered by weblate
2019-04-16 05:00:06 +00:00
Maarten van den Berg
4989b6235c Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3057 of 3057 strings)

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

powered by weblate
2019-04-16 05:00:05 +00:00
mussol
4cfebab11c Translated on translate.pretix.eu (Catalan)
Currently translated at 35.1% (1073 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
Martin Gross
fe944ec643 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3057 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
Martin Gross
9d92c7b10f Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3057 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
Maarten van den Berg
3b810a3a76 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3057 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
Maarten van den Berg
7860417177 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 99.4% (3038 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
Maarten van den Berg
1438edb3c8 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3057 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
mussol
a17720062b Translated on translate.pretix.eu (Catalan)
Currently translated at 31.8% (972 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
mussol
37ab45b352 Translated on translate.pretix.eu (Catalan)
Currently translated at 31.6% (966 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
mussol
6be212df8c Translated on translate.pretix.eu (Catalan)
Currently translated at 31.1% (952 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
mussol
b4e85780f4 Translated on translate.pretix.eu (Catalan)
Currently translated at 30.0% (917 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
mussol
a9732c4788 Translated on translate.pretix.eu (Catalan)
Currently translated at 30.0% (917 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
mussol
273316be25 Translated on translate.pretix.eu (Catalan)
Currently translated at 29.4% (900 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
Maarten van den Berg
0f3b269931 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 98.9% (3023 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
Maarten van den Berg
4462054d0e Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3057 of 3057 strings)

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

powered by weblate
2019-04-15 13:47:00 +00:00
Raphael Michel
ec53022cc8 Do not call task synchronously inside task (celery doesn't allow it any more) 2019-04-15 15:46:37 +02:00
Raphael Michel
0b65b18459 Send emails in an TransactionAwareTask 2019-04-15 15:22:58 +02:00
Raphael Michel
2fac03f47b Add a test case for free orders 2019-04-15 15:14:35 +02:00
Raphael Michel
750d5eda48 Do not mark free orders as paid that require approval 2019-04-15 15:12:26 +02:00
Raphael Michel
f2cd9a2002 Fix logic bug in attachment size check 2019-04-15 12:58:36 +02:00
Raphael Michel
874b38db17 Mark order as paid immediately 2019-04-15 12:58:20 +02:00
Raphael Michel
0f58e1c396 CSV import: Do not skip rows without a reference 2019-04-08 17:55:28 +02:00
Raphael Michel
36e0afc09e Further improvements to the print stylesheet 2019-04-08 17:42:06 +02:00
Raphael Michel
7164124a70 Display category description in add-on step 2019-04-08 15:23:40 +02:00
Raphael Michel
887d8832c0 Improve print CSS of order details 2019-04-07 18:12:12 +02:00
Raphael Michel
beb144f9a0 Fix API log cleanup 2019-04-07 15:31:35 +02:00
Raphael Michel
6d1dea7922 Upgrade to Django 2.2 and modern DRF and py.test (#1246)
* Upgrade django and stuff

* Update to Django 2.2 and recent versions of similar packages

* Provide explicit orderings to all models used in paginated queries

* Resolve naive datetime warnings in test suite

* Deal with deprecation warnings

* Fix sqlparse version
2019-04-07 14:09:49 +01:00
210 changed files with 88383 additions and 35330 deletions

View File

@@ -125,6 +125,8 @@ Example::
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
turns on some optimizations/special case handlers. Default: ``False``
.. _`config-replica`:
Database replica settings
-------------------------
@@ -142,6 +144,8 @@ Example::
[replica]
host=192.168.0.2
.. _`config-urls`:
URLs
----

View File

@@ -11,3 +11,4 @@ This documentation is for everyone who wants to install pretix on a server.
installation/index
config
maintainance
scaling

View File

@@ -1,3 +1,5 @@
.. _`installation`:
Installation guide
==================

236
doc/admin/scaling.rst Normal file
View File

@@ -0,0 +1,236 @@
.. _`scaling`:
Scaling guide
=============
Our :ref:`installation guide <installation>` only covers "small-scale" setups, by which we mostly mean
setups that run on a **single (virtual) machine** and do not encounter large traffic peaks.
We do not offer an installation guide for larger-scale setups of pretix, mostly because we believe that
there is no one-size-fits-all solution for this and the desired setup highly depends on your use case,
the platform you run pretix on, and your technical capabilities. We do not recommend trying set up pretix
in a multi-server environment if you do not already have experience with managing server clusters.
This document is intended to give you a general idea on what issues you will encounter when you scale up
and what you should think of.
.. tip::
If you require more help on this, we're happy to help. Our pretix Enterprise support team has built
and helped building, scaling and load-testing pretix installations at any scale and we're looking
forward to work with you on fine-tuning your system. If you intend to sell **more than a thousand
tickets in a very short amount of time**, we highly recommend reaching out and at least talking this
through. Just get in touch at sales@pretix.eu!
Scaling reasons
---------------
There's mainly two reasons to scale up a pretix installation beyond a single server:
* **Availability:** Distributing pretix over multiple servers can allow you to survive failure of one or more single machines, leading to a higher uptime and reliability of your system.
* **Traffic and throughput:** Distributing pretix over multiple servers can allow you to process more web requests and ticket sales at the same time.
You are very unlikely to require scaling for other reasons, such as having too much data in your database.
Components
----------
A pretix installation usually consists of the following components which run performance-relevant processes:
* ``pretix-web`` is the Django-based web application that serves all user interaction.
* ``pretix-worker`` is a Celery-based application that processes tasks that should be run asynchronously outside of the web application process.
* A **SQL database** keeps all the important data and processes the actual transactions. We recommend using PostgreSQL, but MySQL/MariaDB works as well.
* A **web server** that terminates TLS and HTTP connections and forwards them to ``pretix-web``. In some cases, e.g. when serving static files, the web servers might return a response directly. We recommend using ``nginx``.
* A **redis** server responsible for the communication between ``pretix-web`` and ``pretix-worker``, as well as for caching.
* A directory of **media files** such as user-uploaded files or generated files (tickets, invoices, …) that are created and used by ``pretix-web``, ``pretix-worker`` and the web server.
In the following, we will discuss the scaling behavior of every component individually. In general, you can run all of the components
on the same server, but you can just as well distribute every component to its own server, or even use multiple servers for some single
components.
.. warning::
When setting up your system, don't forget about security. In a multi-server environment,
you need to take special care to ensure that no unauthorized access to your database
is possible through the network and that it's not easy to wiretap your connections. We
recommend a rigorous use of firewalls and encryption on all communications. You can
ensure this either on an application level (such as using the TLS support in your
database) or on a network level with a VPN solution.
Web server
""""""""""
Your web server is at the very front of your installation. It will need to absorb all of the traffic, and it should be able to
at least show a decent error message, even when everything else fails. Luckily, web servers are really fast these days, so this
can be achieved without too much work.
We recommend reading up on tuning your web server for high concurrency. For nginx, this means thinking about the number of worker
processes and the number of connections each worker process accepts. Double-check that TLS session caching works, because TLS
handshakes can get really expensive.
During a traffic peak, your web server will be able to make us of more CPU resources, while memory usage will stay comparatively low,
so if you invest in more hardware here, invest in more and faster CPU cores.
Make sure that pretix' static files (such as CSS and JavaScript assets) as well as user-uploaded media files (event logos, etc)
are served directly by your web server and your web server caches them in-memory (nginx does it by default) and sets useful
headers for client-side caching. As an additional performance improvement, you can turn of access logging for these types of files.
If you want, you can even farm out serving static files to a different web server entirely and :ref:`configure pretix to reference
them from a different URL <config-urls>`.
.. tip::
If you expect *really high traffic* for your very popular event, you might want to do some rate limiting on this layer, or,
if you want to ensure a fair and robust first-come-first-served experience and prefer letting users wait over showing them
errors, consider a queuing solution. We're happy to provide you with such systems, just get in touch at sales@pretix.eu.
pretix-web
""""""""""
The ``pretix-web`` process does not carry any internal state can be easily started on as many machines as you like, and you can
use the load balancing features of your frontend web server to redirect to all of them.
You can adjust the number of processes in the ``gunicorn`` command line, and we recommend choosing roughly two times the number
of CPU cores available. Under load, the memory consumption of ``pretix-web`` will stay comparatively constant, while the CPU usage
will increase a lot. Therefore, if you can add more or faster CPU cores, you will be able to serve more users.
pretix-worker
"""""""""""""
The ``pretix-worker`` process performs all operations that are not directly executed in the request-response-cycle of ``pretix-web``.
Just like ``pretix-web`` you can easily start up as many instances as you want on different machines to share the work. As long as they
all talk to the same redis server, they will all receive tasks from ``pretix-web``, work on them and post their result back.
You can configure the number of threads that run tasks in parallel through the ``--concurrency`` command line option of ``celery``.
Just like ``pretix-web``, this process is mostly heavy on CPU, disk IO and network IO, although memory peaks can occur e.g. during the
generation of large PDF files, so we recommend having some reserves here.
``pretix-worker`` performs a variety of tasks which are of different importance.
Some of them are mission-critical and need to be run quickly even during high load (such as
creating a cart or an order), others are irrelevant and can easily run later (such as
distributing tickets on the waiting list). You can fine-tune the capacity you assign to each
of these tasks by running ``pretix-worker`` processes that only work on a specific **queue**.
For example, you could have three servers dedicated only to process order creations and one
server dedicated only to sending emails. This allows you to set priorities and also protects
you from e.g. a slow email server lowering your ticket throughput.
You can do so by specifying one or more queues on the ``celery`` command line of this process, such as ``celery -A pretix.celery_app worker -Q notifications,mail``. Currently,
the following queues exist:
* ``checkout`` -- This queue handles everything related to carts and orders and thereby everything required to process a sale. This includes adding and deleting items from carts as well as creating and canceling orders.
* ``mail`` -- This queue handles sending of outgoing emails.
* ``notifications`` -- This queue handles the processing of any outgoing notifications, such as email notifications to admin users (except for the actual sending) or API notifications to registered webhooks.
* ``background`` -- This queue handles tasks that are expected to take long or have no human waiting for their result immediately, such as refreshing caches, re-generating CSS files, assigning tickets on the waiting list or parsing bank data files.
* ``default`` -- This queue handles everything else with "medium" or unassigned priority, most prominently the generation of files for tickets, invoices, badges, admin exports, etc.
Media files
"""""""""""
Both ``pretix-web``, ``pretix-worker`` and in some cases your webserver need to work with
media files. Media files are all files generated *at runtime* by the software. This can
include files uploaded by the event organizers, such as the event logo, files uploaded by
ticket buyers (if you use such features) or files generated by the software, such as
ticket files, invoice PDFs, data exports or customized CSS files.
Those files are by default stored to the ``media/`` sub-folder of the data directory given
in the ``pretix.cfg`` configuration file. Inside that ``media/`` folder, you will find a
``pub/`` folder containing the subset of files that should be publicly accessible through
the web server. Everything else only needs to be accessible by ``pretix-web`` and
``pretix-worker`` themselves.
If you distribute ``pretix-web`` or ``pretix-worker`` across more than one machine, you
**must** make sure that they all have access to a shared storage to read and write these
files, otherwise you **will** run into errors with the user interface.
The easiest solution for this is probably to store them on a NFS server that you mount
on each of the other servers.
Since we use Django's file storage mechanism internally, you can in theory also use a object-storage solution like Amazon S3, Ceph, or Minio to store these files, although we currently do not expose this through pretix' configuration file and this would require you to ship your own variant of ``pretix/settings.py`` and reference it through the ``DJANGO_SETTINGS_MODULE`` environment variable.
At pretix.eu, we use a custom-built `object storage cluster`_.
SQL database
""""""""""""
One of the most critical parts of the whole setup is the SQL database -- and certainly the
hardest to scale. Tuning relational databases is an art form, and while there's lots of
material on it on the internet, there's not a single recipe that you can apply to every case.
As a general rule of thumb, the more resources you can give your databases, the better.
Most databases will happily use all CPU cores available, but only use memory up to an amount
you configure, so make sure to set this memory usage as high as you can afford. Having more
memory available allows your database to make more use of caching, which is usually good.
Scaling your database to multiple machines needs to be treated with great caution. It's a
good to have a replica of your database for availability reasons. In case your primary
database server fails, you can easily switch over to the replica and continue working.
However, using database replicas for performance gains is much more complicated. When using
replicated database systems, you are always trading in consistency or availability to get
additional performance and the consequences of this can be subtle and it is important
that you have a deep understanding of the semantics of your replication mechanism.
.. warning::
Using an off-the-shelf database proxy solution that redirects read queries to your
replicas and write queries to your primary database **will lead to very nasty bugs.**
As an example, if you buy a ticket, pretix first needs to calculate how many tickets
are left to sell. If this calculation is done on a database replica that lags behind
even for fractions of a second, the decision to allow selling the ticket will be made
on out-of-data data and you can end up with more tickets sold than configured. Similarly,
you could imagine situations leading to double payments etc.
If you do have a replica, you *can* tell pretix about it :ref:`in your configuration <config-replica>`.
This way, pretix can offload complex read-only queries to the replica when it is safe to do so.
As of pretix 2.7, this is mainly used for search queries in the backend and for rendering the
product list and event lists in the frontend, but we plan on expanding this in the future.
Therefore, for now our clear recommendation is: Try to scale your database vertically and put
it on the most powerful machine you have available.
redis
"""""
While redis is a very important part that glues together some of the components, it isn't used
heavily and can usually handle a fairly large pretix installation easily on a single modern
CPU core.
Having some memory available is good in case of e.g. lots of tasks queuing up during a traffic peak, but we wouldn't expect ever needing more than a gigabyte of it.
Feel free to set up a redis cluster for availability but you won't need it for performance in a long time.
The limitations
---------------
Up to a certain point, pretix scales really well. However, there are a few things that we consider
even more important than scalability, and those are correctness and reliability. We want you to be
able to trust that pretix will not sell more tickets than you intended or run into similar error
cases.
Combined with pretix' flexibility and complexity, especially around vouchers and quotas, this creates
some hard issues. In many cases, we need to fall back to event-global locking for some actions which
are likely to run with high concurrency and cause harm.
For every event, only one of these locking actions can be run at the same time. Examples for this are
adding products limited by a quota to a cart, adding items to a cart using a voucher or placing an order
consisting of cart positions that don't have a valid reservation for much longer. In these cases, it is
currently not realistically possible to exceed selling **approx. 500 orders per minute per event**, even
if you add more hardware.
If you have an unlimited number of tickets, we can apply fewer locking and we've reached **approx.
1500 orders per minute per event** in benchmarks, although even more should be possible.
We're working to reduce the number of cases in which this is relevant and thereby improve the possible
throughput. If you want to use pretix for an event with 10,000+ tickets that are likely to be sold out
within minutes, please get in touch to discuss possible solutions. We'll work something out for you!
.. _object storage cluster: https://behind.pretix.eu/2018/03/20/high-available-cdn/

View File

@@ -336,11 +336,24 @@ Order position endpoints
The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``.
.. versionchanged:: 2.7
The resource now contains the new attributes ``require_attention`` and ``order__status`` and accepts the new
``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint
returns ``400`` instead of ``404`` on tickets which are known but not paid.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
Returns a list of all order positions within a given event. The result is the same as
the :ref:`order-position-resource`, with one important difference: the ``checkins`` value will only include
check-ins for the selected list.
the :ref:`order-position-resource`, with the following differences:
* The ``checkins`` value will only include check-ins for the selected list.
* An additional boolean property ``require_attention`` will inform you whether either the order or the item
have the ``checkin_attention`` flag set.
* If ``attendee_name`` is empty, it will automatically fall back to values from a parent product or from invoice
addresses.
**Example request**:
@@ -407,6 +420,8 @@ Order position endpoints
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query string ignore_status: If set to ``true``, results will be returned regardless of the state of
the order they belong to and you will need to do your own filtering by order status.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``order__code``,
``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default:
``attendee_name,positionid``
@@ -442,8 +457,15 @@ Order position endpoints
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/
Returns information on one order position, identified by its internal ID.
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
``checkins`` value will only include check-ins for the selected list.
The result is the same as the :ref:`order-position-resource`, with the following differences:
* The ``checkins`` value will only include check-ins for the selected list.
* An additional boolean property ``require_attention`` will inform you whether either the order or the item
have the ``checkin_attention`` flag set.
* If ``attendee_name`` is empty, it will automatically fall back to values from a parent product or from invoice
addresses.
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
@@ -528,7 +550,8 @@ Order position endpoints
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
questions that have not been filled. Defaults to ``false``.
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
Defaults to ``false``.
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
list.
:<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
this request twice with the same nonce, the second request will also succeed but will always
create only one check-in object even when the previous request was successful as well. This

View File

@@ -18,12 +18,18 @@ default_price money (string) The price set d
price money (string) The price used for this variation. This is either the
same as ``default_price`` if that value is set or equal
to the item's ``default_price`` (read-only).
original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
active boolean If ``false``, this variation will not be sold or shown.
description multi-lingual string A public description of the variation. May contain
Markdown syntax or can be ``null``.
position integer An integer, used for sorting
===================================== ========================== =======================================================
.. versionchanged:: 2.7
The attribute ``original_price`` has been added.
.. versionchanged:: 1.12
This resource has been added.
@@ -67,7 +73,8 @@ Endpoints
},
"position": 0,
"default_price": "223.00",
"price": 223.0
"price": 223.0,
"original_price": null,
},
{
"id": 3,
@@ -120,6 +127,7 @@ Endpoints
},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -167,6 +175,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -216,6 +225,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": false,
"description": null,
"position": 1

View File

@@ -29,7 +29,8 @@ free_price boolean If ``true``, cu
they buy the product (however, the price can't be set
lower than the price defined by ``default_price`` or
otherwise).
tax_rate decimal (string) The VAT rate to be applied for this item.
tax_rate decimal (string) The VAT rate to be applied for this item (read-only,
set through ``tax_rule``).
tax_rule integer The internal ID of the applied tax rule (or ``null``).
admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others
@@ -81,6 +82,8 @@ variations list of objects A list with one
├ price money (string) The price used for this variation. This is either the
same as ``default_price`` if that value is set or equal
to the item's ``default_price``.
├ original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
├ active boolean If ``false``, this variation will not be sold or shown.
├ description multi-lingual string A public description of the variation. May contain
Markdown syntax or can be ``null``.
@@ -105,6 +108,10 @@ bundles list of objects Definition of b
taxation. This is not added to the price.
===================================== ========================== =======================================================
.. versionchanged:: 2.7
The attribute ``original_price`` has been added for ``variations``.
.. versionchanged:: 1.7
The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute
@@ -207,6 +214,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -215,6 +223,7 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
@@ -296,6 +305,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -304,6 +314,7 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
@@ -365,6 +376,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -373,6 +385,7 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
@@ -423,6 +436,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -431,6 +445,7 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
@@ -512,6 +527,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -520,6 +536,7 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1

View File

@@ -30,6 +30,7 @@ type string The expected ty
* ``D`` date
* ``H`` time
* ``W`` date and time
* ``CC`` country code (ISO 3666-1 alpha-2)
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.
@@ -38,6 +39,8 @@ identifier string An arbitrary st
ask_during_checkin boolean If ``true``, this question will not be asked while
buying the ticket, but will show up when redeeming
the ticket instead.
hidden boolean If ``true``, the question will only be shown in the
backend.
options list of objects In case of question type ``C`` or ``M``, this lists the
available objects. Only writable during creation,
use separate endpoint to modify this later.
@@ -68,6 +71,10 @@ dependency_value string The value ``dep
Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the
options resource. The ``position`` attribute has been added to the options resource.
.. versionchanged:: 2.7
The attribute ``hidden`` and the question type ``CC`` have been added.
Endpoints
---------
@@ -110,6 +117,7 @@ Endpoints
"position": 1,
"identifier": "WY3TP9SL",
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
@@ -177,6 +185,7 @@ Endpoints
"position": 1,
"identifier": "WY3TP9SL",
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
@@ -228,6 +237,7 @@ Endpoints
"items": [1, 2],
"position": 1,
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
@@ -261,6 +271,7 @@ Endpoints
"position": 1,
"identifier": "WY3TP9SL",
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
@@ -332,6 +343,7 @@ Endpoints
"position": 2,
"identifier": "WY3TP9SL",
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [

View File

@@ -20,6 +20,8 @@ name multi-lingual string The sub-event's
event string The slug of the parent event
active boolean If ``true``, the sub-event ticket shop is publicly
available.
is_public boolean If ``true``, the sub-event ticket shop is publicly
shown in lists.
date_from datetime The sub-event's start date
date_to datetime The sub-event's end date (or ``null``)
date_admission datetime The sub-event's admission date (or ``null``)
@@ -48,6 +50,10 @@ meta_data dict Values set for
.. versionchanged:: 2.6
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 2.7
The attribute ``is_public`` has been added.
Endpoints
---------
@@ -81,6 +87,7 @@ Endpoints
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
@@ -128,6 +135,7 @@ Endpoints
{
"name": {"en": "First Sample Conference"},
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
@@ -157,6 +165,7 @@ Endpoints
"id": 1,
"name": {"en": "First Sample Conference"},
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
@@ -207,6 +216,7 @@ Endpoints
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
@@ -270,6 +280,7 @@ Endpoints
"name": {"en": "New Subevent Name"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
@@ -353,6 +364,7 @@ Endpoints
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,

View File

@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data, register_sales_channels
item_copy_data, register_sales_channels, register_global_settings
Order events
""""""""""""
@@ -26,11 +26,7 @@ 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
.. automodule:: pretix.presale.signals
:members: order_info, order_meta_from_request
: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, checkout_flow_steps, order_info, order_meta_from_request
Request flow
""""""""""""

View File

@@ -49,15 +49,19 @@ description string A more verbose description of what your
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
for an event by system administrators / superusers.
compatibility string Specifier for compatible pretix versions.
================== ==================== ===========================================================
A working example would be::
from django.apps import AppConfig
try:
from pretix.base.plugins import PluginConfig
except ImportError:
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
from django.utils.translation import ugettext_lazy as _
class PaypalApp(AppConfig):
class PaypalApp(PluginConfig):
name = 'pretix_paypal'
verbose_name = _("PayPal")
@@ -68,6 +72,7 @@ A working example would be::
visible = True
restricted = False
description = _("This plugin allows you to receive payments via PayPal")
compatibility = "pretix>=2.7.0"
default_app_config = 'pretix_paypal.PaypalApp'

View File

@@ -23,7 +23,7 @@ Organizers and events
:members:
.. autoclass:: pretix.base.models.Event
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, active_subevents, invoice_renderer, settings
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, invoice_renderer, settings
.. autoclass:: pretix.base.models.SubEvent
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running

View File

@@ -15,6 +15,7 @@ boolean
booleans
cancelled
casted
Ceph
checkbox
checksum
config
@@ -53,6 +54,7 @@ linters
memcached
metadata
middleware
Minio
mixin
mixins
multi
@@ -94,6 +96,7 @@ renderer
renderers
reportlab
SaaS
scalability
screenshot
scss
searchable
@@ -124,6 +127,7 @@ unconfigured
unix
unprefixed
untrusted
uptime
username
url
versa

View File

@@ -1 +1 @@
__version__ = "2.6.0"
__version__ = "2.7.0"

View File

@@ -1,7 +1,8 @@
from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken
from pretix.base.models import Device, Event
from pretix.base.models import Device, Event, User
from pretix.base.models.auth import SuperuserPermissionSet
from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
@@ -37,10 +38,13 @@ class EventPermission(BasePermission):
slug=request.resolver_match.kwargs['event'],
organizer__slug=request.resolver_match.kwargs['organizer'],
).select_related('organizer').first()
if not request.event or not perm_holder.has_event_permission(request.event.organizer, request.event):
if not request.event or not perm_holder.has_event_permission(request.event.organizer, request.event, request=request):
return False
request.organizer = request.event.organizer
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
request.eventpermset = SuperuserPermissionSet()
else:
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
if required_permission and required_permission not in request.eventpermset:
return False
@@ -49,9 +53,12 @@ class EventPermission(BasePermission):
request.organizer = Organizer.objects.filter(
slug=request.resolver_match.kwargs['organizer'],
).first()
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer):
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request):
return False
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
request.orgapermset = SuperuserPermissionSet()
else:
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
if required_permission and required_permission not in request.orgapermset:
return False

View File

@@ -21,15 +21,15 @@ class IdempotencyMiddleware:
if not request.path.startswith('/api/'):
return self.get_response(request)
if not request.META.get('HTTP_X_IDEMPOTENCY_KEY'):
if not request.headers.get('X-Idempotency-Key'):
return self.get_response(request)
auth_hash_parts = '{}:{}'.format(
request.META.get('HTTP_AUTHORIZATION', ''),
request.headers.get('Authorization', ''),
request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
)
auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
idempotency_key = request.META.get('HTTP_X_IDEMPOTENCY_KEY', '')
idempotency_key = request.headers.get('X-Idempotency-Key', '')
with transaction.atomic():
call, created = ApiCall.objects.select_for_update().get_or_create(

View File

@@ -77,6 +77,9 @@ class WebHook(models.Model):
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
class Meta:
ordering = ('id',)
@property
def action_types(self):
return [

View File

@@ -199,7 +199,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
class Meta:
model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'event',
'presale_start', 'presale_end', 'location', 'event', 'is_public',
'item_price_overrides', 'variation_price_overrides', 'meta_data')
def validate(self, data):
@@ -212,8 +212,8 @@ class SubEventSerializer(I18nAwareModelSerializer):
Event.clean_dates(data.get('date_from'), data.get('date_to'))
Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
SubEvent.clean_items(event, [item['item'] for item in full_data.get('subeventitem_set')])
SubEvent.clean_variations(event, [item['variation'] for item in full_data.get('subeventitemvariation_set')])
SubEvent.clean_items(event, [item['item'] for item in full_data.get('subeventitem_set', [])])
SubEvent.clean_variations(event, [item['variation'] for item in full_data.get('subeventitemvariation_set', [])])
return data
def validate_item_price_overrides(self, data):

View File

@@ -19,7 +19,7 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price')
'position', 'default_price', 'price', 'original_price')
class ItemVariationSerializer(I18nAwareModelSerializer):
@@ -29,7 +29,7 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price')
'position', 'default_price', 'price', 'original_price')
class InlineItemBundleSerializer(serializers.ModelSerializer):
@@ -207,7 +207,8 @@ class QuestionSerializer(I18nAwareModelSerializer):
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value')
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value',
'hidden')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)

View File

@@ -14,8 +14,8 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
Question, QuestionAnswer,
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, SubEvent,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -179,6 +179,55 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
self.fields.pop('pdf_data')
class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition):
return instance.order.checkin_attention or instance.item.checkin_attention
class AttendeeNameField(serializers.Field):
def to_representation(self, instance: OrderPosition):
an = instance.attendee_name
if not an:
if instance.addon_to_id:
an = instance.addon_to.attendee_name
if not an:
try:
an = instance.order.invoice_address.name
except InvoiceAddress.DoesNotExist:
pass
return an
class AttendeeNamePartsField(serializers.Field):
def to_representation(self, instance: OrderPosition):
an = instance.attendee_name
p = instance.attendee_name_parts
if not an:
if instance.addon_to_id:
an = instance.addon_to.attendee_name
p = instance.addon_to.attendee_name_parts
if not an:
try:
p = instance.order.invoice_address.name_parts
except InvoiceAddress.DoesNotExist:
pass
return p
class CheckinListOrderPositionSerializer(OrderPositionSerializer):
require_attention = RequireAttentionField(source='*')
attendee_name = AttendeeNameField(source='*')
attendee_name_parts = AttendeeNamePartsField(source='*')
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
class Meta:
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', 'require_attention',
'order__status')
class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2
def to_representation(self, instance: Order):
@@ -288,6 +337,23 @@ class OrderSerializer(I18nAwareModelSerializer):
return instance
class PriceCalcSerializer(serializers.Serializer):
item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
locale = serializers.CharField(allow_null=True, required=False)
def __init__(self, *args, **kwargs):
event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['item'].queryset = event.items.all()
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=event)
if event.has_subevents:
self.fields['subevent'].queryset = event.subevents.all()
else:
del self.fields['subevent']
class AnswerCreateSerializer(I18nAwareModelSerializer):
class Meta:

View File

@@ -23,4 +23,4 @@ def cleanup_webhook_logs(sender, **kwargs):
@receiver(periodic_task)
def cleanup_api_logs(sender, **kwargs):
ApiCall.objects.filter(datetime__lte=now() - timedelta(hours=24)).delete()
ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete()

View File

@@ -31,10 +31,10 @@ class RichOrderingFilter(OrderingFilter):
class ConditionalListView:
def list(self, request, **kwargs):
if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE')
if_modified_since = request.headers.get('If-Modified-Since')
if if_modified_since:
if_modified_since = parse_http_date_safe(if_modified_since)
if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
if_unmodified_since = request.headers.get('If-Unmodified-Since')
if if_unmodified_since:
if_unmodified_since = parse_http_date_safe(if_unmodified_since)
if not hasattr(request, 'event'):

View File

@@ -7,13 +7,13 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import detail_route
from rest_framework.decorators import action
from rest_framework.fields import DateTimeField
from rest_framework.response import Response
from pretix.api.serializers.checkin import CheckinListSerializer
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.order import CheckinListOrderPositionSerializer
from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter
from pretix.base.models import (
@@ -77,7 +77,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
)
super().perform_destroy(instance)
@detail_route(methods=['GET'])
@action(detail=True, methods=['GET'])
def status(self, *args, **kwargs):
clist = self.get_object()
cqs = Checkin.objects.filter(
@@ -153,7 +153,7 @@ class CheckinOrderPositionFilter(OrderPositionFilter):
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid')
@@ -189,7 +189,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
except ValueError:
raise Http404()
def get_queryset(self):
def get_queryset(self, ignore_status=False):
cqs = Checkin.objects.filter(
position_id=OuterRef('pk'),
list_id=self.checkinlist.pk
@@ -199,11 +199,15 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
qs = OrderPosition.objects.filter(
order__event=self.request.event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID],
subevent=self.checkinlist.subevent
).annotate(
last_checked_in=Subquery(cqs)
)
if self.request.query_params.get('ignore_status', 'false') != 'true' and not ignore_status:
qs = qs.filter(
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID]
)
if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related(
Prefetch(
@@ -225,7 +229,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to'
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address'
)
else:
qs = qs.prefetch_related(
@@ -235,19 +239,19 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order')
if not self.checkinlist.all_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
return qs
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def redeem(self, *args, **kwargs):
force = bool(self.request.data.get('force', False))
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce')
op = self.get_object()
op = self.get_object(ignore_status=True)
if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
@@ -281,7 +285,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
return Response({
'status': 'incomplete',
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data,
'questions': [
QuestionSerializer(q).data for q in e.questions
]
@@ -291,17 +295,17 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
'status': 'error',
'reason': e.code,
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
}, status=400)
else:
return Response({
'status': 'ok',
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
}, status=201)
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
def get_object(self, ignore_status=False):
queryset = self.filter_queryset(self.get_queryset(ignore_status=ignore_status))
if self.kwargs['pk'].isnumeric():
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:

View File

@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import detail_route
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
@@ -499,7 +499,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
)
super().perform_destroy(instance)
@detail_route(methods=['get'])
@action(detail=True, methods=['get'])
def availability(self, request, *args, **kwargs):
quota = self.get_object()

View File

@@ -12,7 +12,7 @@ from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import detail_route
from rest_framework.decorators import action
from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
)
@@ -24,14 +24,16 @@ from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
)
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
Order, OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
generate_position_secret, generate_secret,
)
from pretix.base.payment import PaymentException
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice,
@@ -41,10 +43,12 @@ from pretix.base.services.orders import (
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded,
)
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,
)
from pretix.base.templatetags.money import money_filter
class OrderFilter(FilterSet):
@@ -127,7 +131,7 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date})
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
order = self.get_object()
@@ -149,7 +153,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return resp
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs):
order = self.get_object()
@@ -190,7 +194,7 @@ class OrderViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def mark_canceled(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
cancellation_fee = request.data.get('cancellation_fee', None)
@@ -224,7 +228,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def approve(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
@@ -242,7 +246,7 @@ class OrderViewSet(viewsets.ModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def deny(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
comment = request.data.get('comment', '')
@@ -260,7 +264,7 @@ class OrderViewSet(viewsets.ModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def mark_pending(self, request, **kwargs):
order = self.get_object()
@@ -279,7 +283,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def mark_expired(self, request, **kwargs):
order = self.get_object()
@@ -296,7 +300,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def mark_refunded(self, request, **kwargs):
order = self.get_object()
@@ -313,7 +317,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def create_invoice(self, request, **kwargs):
order = self.get_object()
has_inv = order.invoices.exists() and not (
@@ -345,7 +349,7 @@ class OrderViewSet(viewsets.ModelViewSet):
status=status.HTTP_201_CREATED
)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def resend_link(self, request, **kwargs):
order = self.get_object()
if not order.email:
@@ -359,7 +363,7 @@ class OrderViewSet(viewsets.ModelViewSet):
status=status.HTTP_204_NO_CONTENT
)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
@transaction.atomic
def regenerate_secrets(self, request, **kwargs):
order = self.get_object()
@@ -370,6 +374,8 @@ class OrderViewSet(viewsets.ModelViewSet):
order.save(update_fields=['secret'])
CachedTicket.objects.filter(order_position__order=order).delete()
CachedCombinedTicket.objects.filter(order=order).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk,
'order': order.pk})
order.log_action(
'pretix.event.order.secret.changed',
user=self.request.user,
@@ -377,7 +383,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def extend(self, request, **kwargs):
new_date = request.data.get('expires', None)
force = request.data.get('force', False)
@@ -508,6 +514,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
serializer.save()
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.event.pk, 'order': serializer.instance.pk})
if 'invoice_address' in self.request.data:
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
@@ -619,7 +626,84 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
return prov
raise NotFound('Unknown output provider.')
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
@action(detail=True, methods=['POST'], url_name='price_calc')
def price_calc(self, request, *args, **kwargs):
"""
This calculates the price assuming a change of product or subevent. This endpoint
is deliberately not documented and considered a private API, only to be used by
pretix' web interface.
Sample input:
{
"item": 2,
"variation": null,
"subevent": 3
}
Sample output:
{
"gross": "2.34",
"gross_formatted": "2,34",
"net": "2.34",
"tax": "0.00",
"rate": "0.00",
"name": "VAT"
}
"""
serializer = PriceCalcSerializer(data=request.data, event=request.event)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
pos = self.get_object()
try:
ia = pos.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress()
kwargs = {
'item': pos.item,
'variation': pos.variation,
'voucher': pos.voucher,
'subevent': pos.subevent,
'addon_to': pos.addon_to,
'invoice_address': ia,
}
if data.get('item'):
item = data.get('item')
kwargs['item'] = item
if item.has_variations:
variation = data.get('variation') or pos.variation
if not variation:
raise ValidationError('No variation given')
if variation.item != item:
raise ValidationError('Variation does not belong to item')
kwargs['variation'] = variation
else:
variation = None
kwargs['variation'] = None
if pos.voucher and not pos.voucher.applies_to(item, variation):
kwargs['voucher'] = None
if data.get('subevent'):
kwargs['subevent'] = data.get('subevent')
price = get_price(**kwargs)
with language(data.get('locale') or self.request.event.settings.locale):
return Response({
'gross': price.gross,
'gross_formatted': money_filter(price.gross, self.request.event.currency, hide_currency=True),
'net': price.net,
'rate': price.rate,
'name': str(price.name),
'tax': price.tax,
})
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
pos = self.get_object()
@@ -670,7 +754,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.payments.all()
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def confirm(self, request, **kwargs):
payment = self.get_object()
force = request.data.get('force', False)
@@ -691,7 +775,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
pass
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def refund(self, request, **kwargs):
payment = self.get_object()
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
@@ -756,7 +840,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
payment.order.save(update_fields=['status', 'expires'])
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def cancel(self, request, **kwargs):
payment = self.get_object()
@@ -784,7 +868,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.refunds.all()
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def cancel(self, request, **kwargs):
refund = self.get_object()
@@ -801,7 +885,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def process(self, request, **kwargs):
refund = self.get_object()
@@ -826,7 +910,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
refund.order.save(update_fields=['status', 'expires'])
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def done(self, request, **kwargs):
refund = self.get_object()
@@ -916,7 +1000,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
nr=Concat('prefix', 'invoice_no')
)
@detail_route()
@action(detail=True, )
def download(self, request, **kwargs):
invoice = self.get_object()
@@ -934,7 +1018,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def regenerate(self, request, **kwarts):
inv = self.get_object()
if inv.canceled:
@@ -953,7 +1037,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
)
return Response(status=204)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def reissue(self, request, **kwarts):
inv = self.get_object()
if inv.canceled:

View File

@@ -7,7 +7,7 @@ from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet,
)
from rest_framework import status, viewsets
from rest_framework.decorators import list_route
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
@@ -116,7 +116,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
instance.cartposition_set.all().delete()
super().perform_destroy(instance)
@list_route(methods=['POST'])
@action(detail=False, methods=['POST'])
def batch_create(self, request, *args, **kwargs):
if any(self._predict_quota_check(d, None) for d in request.data):
lockfn = request.event.lock

View File

@@ -1,7 +1,7 @@
import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import detail_route
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
@@ -69,7 +69,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
)
super().perform_destroy(instance)
@detail_route(methods=['POST'])
@action(detail=True, methods=['POST'])
def send_voucher(self, *args, **kwargs):
try:
self.get_object().send_voucher(

View File

@@ -1,4 +1,5 @@
from .answers import * # noqa
from .dekodi import * # noqa
from .invoices import * # noqa
from .json import * # noqa
from .mail import * # noqa

View File

@@ -0,0 +1,219 @@
import json
from collections import OrderedDict
from decimal import Decimal
import dateutil
from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.dispatch import receiver
from django.utils.translation import ugettext, ugettext_lazy
from pretix.base.i18n import language
from pretix.base.models import Invoice, OrderPayment
from ..exporter import BaseExporter
from ..signals import register_data_exporters
class DekodiNREIExporter(BaseExporter):
identifier = 'dekodi_nrei'
verbose_name = 'dekodi NREI (JSON)'
# Specification: http://manuals.dekodi.de/nexuspub/schnittstellenbuch/
def _encode_invoice(self, invoice: Invoice):
p_last = invoice.order.payments.filter(state=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED]).last()
gross_total = Decimal('0.00')
net_total = Decimal('0.00')
positions = []
for l in invoice.lines.all():
positions.append({
'ADes': l.description.replace("<br />", "\n"),
'ANetA': round(float((-1 if invoice.is_cancellation else 1) * l.net_value), 2),
'ANo': self.event.slug,
'AQ': -1 if invoice.is_cancellation else 1,
'AVatP': round(float(l.tax_rate), 2),
'DIDt': (l.subevent or invoice.order.event).date_from.isoformat().replace('Z', '+00:00'),
'PosGrossA': round(float(l.gross_value), 2),
'PosNetA': round(float(l.net_value), 2),
})
gross_total += l.gross_value
net_total += l.net_value
payments = []
paypal_email = None
for p in invoice.order.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_REFUNDED)
):
if p.provider == 'paypal':
paypal_email = p.info_data.get('payer', {}).get('payer_info', {}).get('email')
try:
ppid = p.info_data['transactions'][0]['related_resources'][0]['sale']['id']
except:
ppid = p.info_data.get('id')
payments.append({
'PTID': '1',
'PTN': 'PayPal',
'PTNo1': ppid,
'PTNo2': p.info_data.get('id'),
'PTNo7': round(float(p.amount), 2),
'PTNo8': str(self.event.currency),
'PTNo11': paypal_email or '',
'PTNo15': p.full_id or '',
})
elif p.provider == 'banktransfer':
payments.append({
'PTID': '4',
'PTN': 'Vorkasse',
'PTNo4': p.info_data.get('reference') or p.payment_provider._code(invoice.order),
'PTNo7': round(float(p.amount), 2),
'PTNo8': str(self.event.currency),
'PTNo10': p.info_data.get('payer') or '',
'PTNo14': p.info_data.get('date') or '',
'PTNo15': p.full_id or '',
})
elif p.provider == 'sepadebit':
with language(invoice.order.locale):
payments.append({
'PTID': '5',
'PTN': 'Lastschrift',
'PTNo4': ugettext('Event ticket {event}-{code}').format(
event=self.event.slug.upper(),
code=invoice.order.code
),
'PTNo5': p.info_data.get('iban') or '',
'PTNo6': p.info_data.get('bic') or '',
'PTNo7': round(float(p.amount), 2),
'PTNo8': str(self.event.currency) or '',
'PTNo9': p.info_data.get('date') or '',
'PTNo10': p.info_data.get('account') or '',
'PTNo14': p.info_data.get('reference') or '',
'PTNo15': p.full_id or '',
})
elif p.provider.startswith('stripe'):
src = p.info_data.get("source", "{}")
payments.append({
'PTID': '81',
'PTN': 'Stripe',
'PTNo1': p.info_data.get("id") or '',
'PTNo5': src.get("card", {}).get("last4") or '',
'PTNo7': round(float(p.amount), 2) or '',
'PTNo8': str(self.event.currency) or '',
'PTNo10': src.get('owner', {}).get('verified_name') or src.get('owner', {}).get('name') or '',
'PTNo15': p.full_id or '',
})
else:
payments.append({
'PTID': '0',
'PTN': p.provider,
'PTNo7': round(float(p.amount), 2) or '',
'PTNo8': str(self.event.currency) or '',
'PTNo15': p.full_id or '',
})
payments = [
{
k: v for k, v in p.items() if v is not None
} for p in payments
]
hdr = {
'C': str(invoice.invoice_to_country) or self.event.settings.invoice_address_from_country,
'CC': self.event.currency,
'City': invoice.invoice_to_city,
'CN': invoice.invoice_to_company,
'DIC': self.event.settings.invoice_address_from_country,
# DIC is a little bit unclean, should be the event location's country
'DIDt': invoice.order.datetime.isoformat().replace('Z', '+00:00'),
'DT': '30' if invoice.is_cancellation else '10',
'EM': invoice.order.email,
'FamN': invoice.invoice_to_name.rsplit(' ', 1)[-1],
'FN': invoice.invoice_to_name.rsplit(' ', 1)[0] if ' ' in invoice.invoice_to_name else '',
'IDt': invoice.date.isoformat() + 'T08:00:00+01:00',
'INo': invoice.full_invoice_no,
'IsNet': invoice.reverse_charge,
'ODt': invoice.order.datetime.isoformat().replace('Z', '+00:00'),
'OID': invoice.order.code,
'SID': self.event.slug,
'SN': str(self.event),
'Str': invoice.invoice_to_street or '',
'TGrossA': round(float(gross_total), 2),
'TNetA': round(float(net_total), 2),
'TVatA': round(float(gross_total - net_total), 2),
'VatDp': False,
'Zip': invoice.invoice_to_zipcode
}
if not hdr['FamN'] and not hdr['CN']:
hdr['CN'] = "Unbekannter Kunde"
if invoice.refers:
hdr['PvrINo'] = invoice.refers.full_invoice_no
if p_last:
hdr['PmDt'] = p_last.payment_date.isoformat().replace('Z', '+00:00')
if paypal_email:
hdr['PPEm'] = paypal_email
if invoice.invoice_to_vat_id:
hdr['VatID'] = invoice.invoice_to_vat_id
return {
'IsValid': True,
'Hdr': hdr,
'InvcPstns': positions,
'PmIs': payments,
'ValidationMessage': ''
}
def render(self, form_data):
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__gte=date_value)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__lte=date_value)
jo = {
'Format': 'NREI',
'Version': '18.10.2.0',
'SourceSystem': 'pretix',
'Data': [
self._encode_invoice(i) for i in qs
]
}
return '{}_nrei.json'.format(self.event.slug), 'application/json', json.dumps(jo, cls=DjangoJSONEncoder, indent=4)
@property
def export_form_fields(self):
return OrderedDict(
[
('date_from',
forms.DateField(
label=ugettext_lazy('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=ugettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
'not always correspond to the order or payment date.')
)),
('date_to',
forms.DateField(
label=ugettext_lazy('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=ugettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
'does not always correspond to the order or payment date.')
)),
]
)
@receiver(register_data_exporters, dispatch_uid="exporter_dekodi_nrei")
def register_dekodi_export(sender, **kwargs):
return DekodiNREIExporter

View File

@@ -6,7 +6,7 @@ from django import forms
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
from django.dispatch import receiver
from django.utils.formats import date_format, localize
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.translation import pgettext, ugettext as _, ugettext_lazy
from pretix.base.models import (
InvoiceAddress, InvoiceLine, Order, OrderPosition,
@@ -269,6 +269,10 @@ class OrderListExporter(MultiSheetListExporter):
_('Status'),
_('Email'),
_('Order date'),
]
if self.event.has_subevents:
headers.append(pgettext('subevent', 'Date'))
headers += [
_('Product'),
_('Variation'),
_('Price'),
@@ -311,6 +315,10 @@ class OrderListExporter(MultiSheetListExporter):
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
]
if self.event.has_subevents:
row.append(op.subevent)
row += [
str(op.item),
str(op.variation) if op.variation else '',
op.price,

View File

@@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from django_countries.fields import CountryField
from pretix.base.forms.widgets import (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
@@ -213,6 +214,14 @@ class BaseQuestionsForm(forms.Form):
widget=forms.Textarea,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_COUNTRYCODE:
field = CountryField().formfield(
label=label, required=required,
help_text=help_text,
widget=forms.Select,
empty_label='',
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
queryset=q.options,

View File

@@ -9,14 +9,17 @@ import vat_moss.exchange_rates
from django.contrib.staticfiles import finders
from django.dispatch import receiver
from django.utils.formats import date_format, localize
from django.utils.translation import get_language, pgettext, ugettext
from django.utils.translation import (
get_language, pgettext, ugettext, ugettext_lazy,
)
from PIL.Image import BICUBIC
from reportlab.lib import pagesizes
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.enums import TA_LEFT, TA_RIGHT
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
@@ -122,6 +125,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
"""
stylesheet = StyleSheet1()
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal']))
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
@@ -254,49 +258,64 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.restoreState()
invoice_to_width = 85 * mm
invoice_to_height = 50 * mm
invoice_to_left = 25 * mm
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas):
p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 85 * mm, 50 * mm)
p_size = p.wrap(85 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
p = Paragraph(self.invoice.address_invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
invoice_from_width = 70 * mm
invoice_from_height = 50 * mm
invoice_from_left = 25 * mm
invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas):
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 70 * mm, 50 * mm)
p_size = p.wrap(70 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
def _on_first_page(self, canvas: Canvas, doc):
canvas.setCreator('pretix.eu')
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
canvas.saveState()
canvas.setFont(self.font_regular, 8)
if self.invoice.order.testmode:
canvas.saveState()
canvas.setFont('OpenSansBd', 30)
canvas.setFillColorRGB(32, 0, 0)
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
canvas.restoreState()
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet[
'InvoiceFrom'])
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
p_size = p.wrap(self.invoice_from_width, self.invoice_from_height)
p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top)
def _draw_invoice_from_label(self, canvas):
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Invoice from')))
canvas.drawText(textobject)
self._draw_invoice_from(canvas)
def _draw_invoice_to_label(self, canvas):
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Invoice to')))
canvas.drawText(textobject)
self._draw_invoice_to(canvas)
logo_width = 25 * mm
logo_height = 25 * mm
logo_left = 95 * mm
logo_top = 13 * mm
logo_anchor = 'n'
def _draw_logo(self, canvas):
if self.invoice.event.settings.invoice_logo_image:
logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
ir = ThumbnailingImageReader(logo_file)
try:
ir.resize(self.logo_width, self.logo_height, 300)
except:
logger.exception("Can not resize image")
pass
canvas.drawImage(ir,
self.logo_left,
self.pagesize[1] - self.logo_height - self.logo_top,
width=self.logo_width, height=self.logo_height,
preserveAspectRatio=True, anchor=self.logo_anchor,
mask='auto')
def _draw_metadata(self, canvas):
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Order code')))
@@ -348,37 +367,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.drawText(textobject)
if self.invoice.event.settings.invoice_logo_image:
logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
ir = ThumbnailingImageReader(logo_file)
try:
ir.resize(25 * mm, 25 * mm, 300)
except:
logger.exception("Can not resize image")
pass
canvas.drawImage(ir,
95 * mm, (297 - 38) * mm,
width=25 * mm, height=25 * mm,
preserveAspectRatio=True, anchor='n',
mask='auto')
event_left = 125 * mm
event_top = 17 * mm
event_width = 65 * mm
event_height = 50 * mm
def _draw_event_label(self, canvas):
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Event')))
canvas.drawText(textobject)
def _draw_event(self, canvas):
def shorten(txt):
txt = str(txt)
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(65 * mm, 50 * mm)
p_size = p.wrap(self.event_width, self.event_height)
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(65 * mm, 50 * mm)
p_size = p.wrap(self.event_width, self.event_height)
return txt
if not self.invoice.event.has_subevents:
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
p_str = (
shorten(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format(
shorten(self.invoice.event.name) + '\n' +
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=self.invoice.event.get_date_from_display(),
to_date=self.invoice.event.get_date_to_display())
to_date=self.invoice.event.get_date_to_display()
)
)
else:
p_str = (
@@ -388,15 +407,38 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p_str = shorten(self.invoice.event.name)
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 65 * mm, 50 * mm)
p_size = p.wrap(65 * mm, 50 * mm)
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(self.event_width, self.event_height)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
self._draw_event_label(canvas)
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Event')))
canvas.drawText(textobject)
def _draw_footer(self, canvas):
canvas.setFont(self.font_regular, 8)
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
def _draw_testmode(self, canvas):
if self.invoice.order.testmode:
canvas.saveState()
canvas.setFont('OpenSansBd', 30)
canvas.setFillColorRGB(32, 0, 0)
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
canvas.restoreState()
def _on_first_page(self, canvas: Canvas, doc):
canvas.setCreator('pretix.eu')
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
canvas.saveState()
self._draw_footer(canvas)
self._draw_testmode(canvas)
self._draw_invoice_from_label(canvas)
self._draw_invoice_from(canvas)
self._draw_invoice_to_label(canvas)
self._draw_invoice_to(canvas)
self._draw_metadata(canvas)
self._draw_logo(canvas)
self._draw_event(canvas)
canvas.restoreState()
def _get_first_page_frames(self, doc):
@@ -434,6 +476,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.stylesheet['Normal']
))
if self.invoice.invoice_to_vat_id:
story.append(Paragraph(
pgettext('invoice', 'Customer VAT ID') + ':<br />' +
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(Paragraph(
pgettext('invoice', 'Beneficiary') + ':<br />' +
@@ -554,6 +603,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)]
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([
Paragraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
table
@@ -607,6 +657,114 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return story
class Modern1Renderer(ClassicInvoiceRenderer):
identifier = 'modern1'
verbose_name = ugettext_lazy('Modern Invoice Renderer (pretix 2.7)')
bottom_margin = 16.9 * mm
top_margin = 16.9 * mm
right_margin = 20 * mm
invoice_to_height = 27.3 * mm
invoice_to_width = 80 * mm
invoice_to_left = 25 * mm
invoice_to_top = (40 + 17.7) * mm
invoice_from_left = 125 * mm
invoice_from_top = 50 * mm
invoice_from_width = pagesizes.A4[0] - invoice_from_left - right_margin
invoice_from_height = 50 * mm
logo_width = 75 * mm
logo_height = 25 * mm
logo_left = pagesizes.A4[0] - logo_width - right_margin
logo_top = top_margin
logo_anchor = 'e'
event_left = 25 * mm
event_top = top_margin
event_width = 80 * mm
event_height = 25 * mm
def _get_stylesheet(self):
stylesheet = super()._get_stylesheet()
stylesheet.add(ParagraphStyle(name='Sender', fontName=self.font_regular, fontSize=8, leading=10))
stylesheet['InvoiceFrom'].alignment = TA_RIGHT
return stylesheet
def _draw_invoice_from(self, canvas):
if not self.invoice.invoice_from:
return
c = self.invoice.address_invoice_from.strip().split('\n')
p = Paragraph(' · '.join(c), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas)
def _draw_invoice_to_label(self, canvas):
pass
def _draw_invoice_from_label(self, canvas):
pass
def _draw_event_label(self, canvas):
pass
def _get_first_page_frames(self, doc):
footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm
return [
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 95 * mm,
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
id='normal')
]
def _draw_metadata(self, canvas):
begin_top = 100 * mm
textobject = canvas.beginText(self.left_margin, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8)
textobject.textLine(pgettext('invoice', 'Order code'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.order.full_code)
canvas.drawText(textobject)
if self.invoice.is_cancellation:
textobject = canvas.beginText(self.left_margin + 50 * mm, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8)
textobject.textLine(pgettext('invoice', 'Cancellation number'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
canvas.drawText(textobject)
textobject = canvas.beginText(self.left_margin + 100 * mm, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8)
textobject.textLine(pgettext('invoice', 'Original invoice'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.refers.number)
canvas.drawText(textobject)
else:
textobject = canvas.beginText(self.left_margin + 70 * mm, self.pagesize[1] - begin_top)
textobject.textLine(pgettext('invoice', 'Invoice number'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
canvas.drawText(textobject)
p = Paragraph(date_format(self.invoice.date, "DATE_FORMAT"), style=self.stylesheet['Normal'])
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
p.wrapOn(canvas, w, 15 * mm)
date_x = self.pagesize[0] - w - self.right_margin
p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6)
textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8)
if self.invoice.is_cancellation:
textobject.textLine(pgettext('invoice', 'Cancellation date'))
else:
textobject.textLine(pgettext('invoice', 'Invoice date'))
canvas.drawText(textobject)
@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic")
def recv_classic(sender, **kwargs):
return ClassicInvoiceRenderer
return [ClassicInvoiceRenderer, Modern1Renderer]

View File

@@ -0,0 +1,51 @@
"""
Django, for theoretically very valid reasons, creates migrations for *every single thing*
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
database backend unknown to us might actually use this information for its database schema.
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
certain that some changes to models will never require a change to the database. In this case,
not creating a migration for certain changes will save us some performance while applying them
*and* allow for a cleaner git history. Win-win!
Only caveat is that we need to do some dirty monkeypatching to achieve it...
"""
from django.core.management.commands.makemigrations import Command as Parent
from django.db import models
from django.db.migrations.operations import models as modelops
from django_countries.fields import CountryField
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
IGNORED_ATTRS = [
# (field type, attribute name, blacklist of field sub-types)
(models.Field, 'verbose_name', []),
(models.Field, 'help_text', []),
(models.Field, 'validators', []),
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
models.TimeField]),
(models.CharField, 'choices', [CountryField])
]
original_deconstruct = models.Field.deconstruct
def new_deconstruct(self):
name, path, args, kwargs = original_deconstruct(self)
for ftype, attr, blacklist in IGNORED_ATTRS:
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in blacklist):
kwargs.pop(attr, None)
return name, path, args, kwargs
models.Field.deconstruct = new_deconstruct
class Command(Parent):
pass

View File

@@ -0,0 +1,28 @@
"""
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
run when there are things we have no migrations for. Usually, this is intended, and running
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
users from doing that by going really dirty and fitlering it from the output.
"""
import sys
from django.core.management.base import OutputWrapper
from django.core.management.commands.migrate import Command as Parent
class OutputFilter(OutputWrapper):
blacklist = (
"Your models have changes that are not yet reflected",
"Run 'manage.py makemigrations' to make new "
)
def write(self, msg, style_func=None, ending=None):
if any(b in msg for b in self.blacklist):
return
super().write(msg, style_func, ending)
class Command(Parent):
def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
super().__init__(stdout, stderr, no_color, force_color)
self.stdout = OutputFilter(stdout or sys.stdout)

View File

@@ -97,7 +97,7 @@ def get_language_from_event(request: HttpRequest) -> str:
def get_language_from_browser(request: HttpRequest) -> str:
accept = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
accept = request.headers.get('Accept-Language', '')
for accept_lang, unused in parse_accept_lang_header(accept):
if accept_lang == '*':
break

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2 on 2019-04-18 11:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0116_auto_20190402_0722'),
]
operations = [
migrations.AddField(
model_name='itemvariation',
name='original_price',
field=models.DecimalField(blank=True, decimal_places=2, help_text='If set, this will be displayed next to '
'the current price to show that the '
'current price is a discounted one. '
'This is just a cosmetic setting and '
'will not actually impact pricing.',
max_digits=7, null=True, verbose_name='Original price'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 2.2 on 2019-04-23 08:39
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0117_auto_20190418_1149'),
]
operations = [
migrations.AddField(
model_name='subevent',
name='is_public',
field=models.BooleanField(default=True, help_text='If selected, this event will show up publicly on the list of dates for your event.', verbose_name='Show in lists'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 2.2 on 2019-05-09 06:54
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0118_auto_20190423_0839'),
]
operations = [
migrations.AddField(
model_name='question',
name='hidden',
field=models.BooleanField(default=False, help_text='This question will only show up in the backend.', verbose_name='Hidden question'),
),
]

View File

@@ -0,0 +1,77 @@
# Generated by Django 2.2 on 2019-05-09 07:36
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0119_auto_20190509_0654'),
]
operations = [
migrations.AlterField(
model_name='cartposition',
name='attendee_name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='cartposition',
name='subevent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
),
migrations.AlterField(
model_name='cartposition',
name='voucher',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
),
migrations.AlterField(
model_name='event',
name='is_public',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='invoiceaddress',
name='name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='item',
name='sales_channels',
field=pretix.base.models.fields.MultiStringField(default=['web']),
),
migrations.AlterField(
model_name='order',
name='sales_channel',
field=models.CharField(default='web', max_length=190),
),
migrations.AlterField(
model_name='orderposition',
name='attendee_name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='orderposition',
name='subevent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
),
migrations.AlterField(
model_name='orderposition',
name='voucher',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
),
migrations.AlterField(
model_name='staffsessionauditlog',
name='method',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(db_index=True, max_length=190, null=True, unique=True),
),
]

View File

@@ -111,6 +111,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
class Meta:
verbose_name = _("User")
verbose_name_plural = _("Users")
ordering = ('email',)
def save(self, *args, **kwargs):
self.email = self.email.lower()

View File

@@ -164,7 +164,7 @@ class EventMixin:
def annotated(cls, qs, channel='web'):
from pretix.base.models import Item, ItemVariation, Quota
sq_active_item = Item.objects.filter_available(channel=channel).filter(
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel).filter(
Q(variations__isnull=True)
& Q(quotas__pk=OuterRef('pk'))
).order_by().values_list('quotas__pk').annotate(
@@ -186,7 +186,7 @@ class EventMixin:
Prefetch(
'quotas',
to_attr='active_quotas',
queryset=Quota.objects.annotate(
queryset=Quota.objects.using(settings.DATABASE_REPLICA).annotate(
active_items=Subquery(sq_active_item, output_field=models.TextField()),
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
).exclude(
@@ -639,22 +639,6 @@ class Event(EventMixin, LoggedModel):
irs = self.get_invoice_renderers()
return irs[self.settings.invoice_renderer]
@property
def active_subevents(self):
"""
Returns a queryset of active subevents.
"""
return self.subevents.filter(active=True).order_by('-date_from', 'name')
@property
def active_future_subevents(self):
return self.subevents.filter(
Q(active=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(date_to__gte=now())
)
).order_by('date_from', 'name')
def subevents_annotated(self, channel):
return SubEvent.annotated(self.subevents, channel)
@@ -667,7 +651,7 @@ class Event(EventMixin, LoggedModel):
'name_descending': ('-name', 'date_from'),
}[ordering]
subevs = queryset.filter(
Q(active=True) & (
Q(active=True) & Q(is_public=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
| Q(date_to__gte=now() - timedelta(hours=24))
)
@@ -833,6 +817,8 @@ class SubEvent(EventMixin, LoggedModel):
:type event: Event
:param active: Whether to show the subevent
:type active: bool
:param is_public: Whether to show the subevent in lists
:type is_public: bool
:param name: This event's full title
:type name: str
:param date_from: The datetime this event starts
@@ -851,6 +837,10 @@ class SubEvent(EventMixin, LoggedModel):
active = models.BooleanField(default=False, verbose_name=_("Active"),
help_text=_("Only with this checkbox enabled, this date is visible in the "
"frontend to users."))
is_public = models.BooleanField(default=True,
verbose_name=_("Show in lists"),
help_text=_("If selected, this event will show up publicly on the list of dates "
"for your event."))
name = I18nCharField(
max_length=200,
verbose_name=_("Name"),

View File

@@ -37,7 +37,7 @@ class MultiStringField(TextField):
def get_prep_lookup(self, lookup_type, value): # NOQA
raise TypeError('Lookups on multi strings are currently not supported.')
def from_db_value(self, value, expression, connection, context):
def from_db_value(self, value, expression, connection):
if value:
return [v for v in value.split(DELIMITER) if v]
else:

View File

@@ -117,12 +117,35 @@ class Invoice(models.Model):
self.invoice_from_name,
self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
str(self.invoice_from_country),
self.invoice_from_country.name if self.invoice_from_country else "",
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id if self.invoice_from_tax_id else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
@property
def address_invoice_from(self):
parts = [
self.invoice_from_name,
self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
self.invoice_from_country.name if self.invoice_from_country else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
@property
def address_invoice_to(self):
if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name:
return self.invoice_to
parts = [
self.invoice_to_company,
self.invoice_to_name,
self.invoice_to_street,
(self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or ""),
self.invoice_to_country.name if self.invoice_to_country else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
def _get_numeric_invoice_number(self):
numeric_invoices = Invoice.objects.filter(
event__organizer=self.event.organizer,

View File

@@ -16,6 +16,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields
@@ -332,7 +333,9 @@ class Item(LoggedModel):
require_bundling = models.BooleanField(
verbose_name=_('Only sell this product as part of a bundle'),
default=False,
help_text=_('If this option is set, the product will only be sold as part of bundle products.')
help_text=_('If this option is set, the product will only be sold as part of bundle products. Do '
'<strong>not</strong> check this option if you want to use this product as an add-on product, '
'but only for fixed bundles!')
)
allow_cancel = models.BooleanField(
verbose_name=_('Allow product to be canceled'),
@@ -550,6 +553,8 @@ class ItemVariation(models.Model):
:type active: bool
:param default_price: This variation's default price
:type default_price: decimal.Decimal
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal
"""
item = models.ForeignKey(
Item,
@@ -578,6 +583,13 @@ class ItemVariation(models.Model):
null=True, blank=True,
verbose_name=_("Default price"),
)
original_price = models.DecimalField(
verbose_name=_('Original price'),
blank=True, null=True,
max_digits=7, decimal_places=2,
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
class Meta:
verbose_name = _("Product variation")
@@ -884,6 +896,8 @@ class Question(LoggedModel):
:param items: A set of ``Items`` objects that this question should be applied to
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
:type ask_during_checkin: bool
:param hidden: Whether to only show the question in the backend
:type hidden: bool
:param identifier: An arbitrary, internal identifier
:type identifier: str
:param dependency_question: This question will only show up if the referenced question is set to `dependency_value`.
@@ -901,6 +915,7 @@ class Question(LoggedModel):
TYPE_DATE = "D"
TYPE_TIME = "H"
TYPE_DATETIME = "W"
TYPE_COUNTRYCODE = "CC"
TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")),
@@ -912,6 +927,7 @@ class Question(LoggedModel):
(TYPE_DATE, _("Date")),
(TYPE_TIME, _("Time")),
(TYPE_DATETIME, _("Date and time")),
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
)
event = models.ForeignKey(
@@ -959,6 +975,11 @@ class Question(LoggedModel):
'pretixdesk 0.2 or newer.'),
default=False
)
hidden = models.BooleanField(
verbose_name=_('Hidden question'),
help_text=_('This question will only show up in the backend.'),
default=False
)
dependency_question = models.ForeignKey(
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
@@ -1062,6 +1083,12 @@ class Question(LoggedModel):
return dt
except:
raise ValidationError(_('Invalid datetime input.'))
elif self.type == Question.TYPE_COUNTRYCODE and answer:
c = Country(answer.upper())
if c.name:
return answer
else:
raise ValidationError(_('Unknown country code.'))
return answer
@@ -1281,7 +1308,8 @@ class Quota(LoggedModel):
'cached_availability_state', 'cached_availability_number', 'cached_availability_time',
'cached_availability_paid_orders'
],
clear_cache=False
clear_cache=False,
using='default'
)
if _cache is not None:
@@ -1391,7 +1419,7 @@ class Quota(LoggedModel):
@staticmethod
def clean_variations(items, variations):
for variation in variations:
for variation in (variations or []):
if variation.item not in items:
raise ValidationError(_('All variations must belong to an item contained in the items list.'))
break

View File

@@ -24,7 +24,7 @@ from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import CountryField
from django_countries.fields import Country, CountryField
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
@@ -32,6 +32,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.models import User
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.locking import NoLockManager
from pretix.base.settings import PERSON_NAME_SCHEMES
from .base import LockModel, LoggedModel
@@ -606,7 +607,7 @@ class Order(LockModel, LoggedModel):
), tz)
return term_last
def _can_be_paid(self, count_waitinglist=True) -> Union[bool, str]:
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]:
error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."),
@@ -617,13 +618,13 @@ class Order(LockModel, LoggedModel):
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last:
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
return True
if not self.event.settings.get('payment_term_accept_late'):
if not self.event.settings.get('payment_term_accept_late') and not ignore_date:
return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist)
@@ -859,6 +860,8 @@ class QuestionAnswer(models.Model):
return date_format(d, "TIME_FORMAT")
except ValueError:
return self.answer
elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer:
return Country(self.answer).name or self.answer
else:
return self.answer
@@ -975,7 +978,8 @@ class AbstractPosition(models.Model):
if hasattr(self.item, 'questions_to_ask'):
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
else:
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False))
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
hidden=False))
else:
questions = list(copy.copy(q) for q in self.item.questions.all())
@@ -1137,9 +1141,9 @@ class OrderPayment(models.Model):
"""
return self.order.event.get_payment_providers().get(self.provider)
def _mark_paid(self, force, count_waitinglist, user, auth, overpaid=False):
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)
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
if not force and can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid
@@ -1159,7 +1163,7 @@ class OrderPayment(models.Model):
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''):
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='', ignore_date=False, lock=True):
"""
Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required
@@ -1169,6 +1173,7 @@ class OrderPayment(models.Model):
:type count_waitinglist: boolean
:param force: Whether this payment should be marked as paid even if no remaining
quota is available (default: ``False``).
:param ignore_date: Whether this order should be marked as paid even when the last date of payments is over.
:type force: boolean
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
:type send_mail: boolean
@@ -1218,14 +1223,16 @@ class OrderPayment(models.Model):
if payment_sum - refund_sum < self.order.total:
return
if self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12):
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock:
# Performance optimization. In this case, there's really no reason to lock everything and an atomic
# database transaction is more than enough.
with transaction.atomic():
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total)
lockfn = NoLockManager
else:
with self.order.event.lock():
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total)
lockfn = self.order.event.lock
with lockfn():
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total,
ignore_date=ignore_date)
invoice = None
if invoice_qualified(self.order):

View File

@@ -118,6 +118,9 @@ class TaxRule(LoggedModel):
)
custom_rules = models.TextField(blank=True, null=True)
class Meta:
ordering = ('event', 'rate', 'id')
def allow_delete(self):
from pretix.base.models.orders import OrderFee, OrderPosition

View File

@@ -344,6 +344,8 @@ class Voucher(LoggedModel):
a variation).
"""
if self.quota_id:
if variation:
return variation.quotas.filter(pk=self.quota_id).exists()
return item.quotas.filter(pk=self.quota_id).exists()
if self.item_id and not self.variation_id:
return self.item_id == item.pk

View File

@@ -87,6 +87,15 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("123.45 EUR"),
"evaluate": lambda op, order, event: money_filter(op.price, event.currency)
}),
("price_with_addons", {
"label": _("Price including add-ons"),
"editor_sample": _("123.45 EUR"),
"evaluate": lambda op, order, event: money_filter(op.price + sum(
p.price
for p in op.addons.all()
if not p.canceled
), event.currency)
}),
("attendee_name", {
"label": _("Attendee name"),
"editor_sample": _("John Doe"),

View File

@@ -1,8 +1,10 @@
import sys
from enum import Enum
from typing import List
from django.apps import apps
from django.apps import AppConfig, apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class PluginType(Enum):
@@ -39,3 +41,22 @@ def get_all_plugins(event=None) -> List[type]:
plugins,
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
)
class PluginConfig(AppConfig):
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'):
import pkg_resources
try:
pkg_resources.require(self.PretixPluginMeta.compatibility)
except pkg_resources.VersionConflict as e:
print("Incompatible plugins found!")
print("Plugin {} requires you to have {}, but you installed {}.".format(
self.name, e.req, e.dist
))
sys.exit(1)

View File

@@ -1,14 +1,14 @@
from collections import Counter, defaultdict, namedtuple
from datetime import timedelta
from datetime import datetime, time, timedelta
from decimal import Decimal
from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db import DatabaseError, transaction
from django.db.models import Q
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext as _
from pretix.base.i18n import language
@@ -19,8 +19,9 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -134,6 +135,15 @@ class CartManager:
raise CartError(error_messages['not_started'])
if self.event.presale_has_ended:
raise CartError(error_messages['ended'])
if not self.event.has_subevents:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(self.event).date(),
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < self.now_dt:
raise CartError(error_messages['ended'])
def _extend_expiry_of_valid_existing_positions(self):
# Extend this user's cart session to ensure all items in the cart expire at the same time
@@ -152,6 +162,18 @@ class CartManager:
err = error_messages['some_subevent_ended']
cp.addons.all().delete()
cp.delete()
if cp.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(cp.subevent).date(),
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < self.now_dt:
err = error_messages['some_subevent_ended']
cp.addons.all().delete()
cp.delete()
return err
def _update_subevents_cache(self, se_ids: List[int]):
@@ -216,6 +238,16 @@ class CartManager:
if op.subevent and op.subevent.presale_has_ended:
raise CartError(error_messages['ended'])
if op.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(op.subevent).date(),
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < self.now_dt:
raise CartError(error_messages['ended'])
if isinstance(op, self.AddOperation):
if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
raise CartError(error_messages['addon_only'])
@@ -759,7 +791,11 @@ class CartManager:
if available_count == 1:
op.position.expires = self._expiry
op.position.price = op.price.gross
op.position.save()
try:
op.position.save(force_update=True)
except DatabaseError:
# Best effort... The position might have been deleted in the meantime!
pass
elif available_count == 0:
op.position.addons.all().delete()
op.position.delete()
@@ -774,17 +810,33 @@ class CartManager:
CartPosition.objects.bulk_create([p for p in new_cart_positions if not getattr(p, '_answers', None) and not p.pk])
return err
def _require_locking(self):
if self._voucher_use_diff:
# If any vouchers are used, we lock to make sure we don't redeem them to often
return True
if self._quota_diff and any(q.size is not None for q in self._quota_diff):
# If any quotas are affected that are not unlimited, we lock
return True
return False
def commit(self):
self._check_presale_dates()
self._check_max_cart_size()
self._calculate_expiry()
with self.event.lock() as now_dt:
err = self._delete_out_of_timeframe()
err = self.extend_expired_positions() or err
lockfn = NoLockManager
if self._require_locking():
lockfn = self.event.lock
with lockfn() as now_dt:
with transaction.atomic():
self.now_dt = now_dt
self._extend_expiry_of_valid_existing_positions()
err = self._delete_out_of_timeframe()
err = self.extend_expired_positions() or err
err = self._perform_operations() or err
if err:
raise CartError(err)

View File

@@ -120,7 +120,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
positions = list(
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'subevent', 'variation').annotate(
addon_c=Count('addons')
)
).order_by('positionid', 'id')
)
reverse_charge = False

View File

@@ -13,6 +13,18 @@ logger = logging.getLogger('pretix.base.locking')
LOCK_TIMEOUT = 120
class NoLockManager:
def __init__(self):
pass
def __enter__(self):
return now()
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
return False
class LockManager:
def __init__(self, event):
self.event = event

View File

@@ -15,6 +15,7 @@ from pretix.base.email import ClassicMailRenderer
from pretix.base.i18n import language
from pretix.base.models import Event, Invoice, InvoiceAddress, Order
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter
from pretix.celery_app import app
@@ -177,7 +178,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
chain(*task_chain).apply_async()
@app.task(bind=True)
@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, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
order: int=None, attach_tickets=False) -> bool:
@@ -219,14 +220,23 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
args.append((name, content, ct.type))
attach_size += len(content)
if attach_tickets < 4 * 1024 * 1024:
if attach_size < 4 * 1024 * 1024:
# Do not attach more than 4MB, it will bounce way to often.
for a in args:
try:
email.attach(*a)
except:
pass
else:
order.log_action(
'pretix.event.order.email.attachments.skipped',
data={
'subject': 'Attachments skipped',
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
'recipient': '',
'invoices': [],
}
)
email = email_filter.send_chained(event, 'message', message=email, order=order)

View File

@@ -1,7 +1,7 @@
import json
import logging
from collections import Counter, namedtuple
from datetime import datetime, timedelta
from datetime import datetime, time, timedelta
from decimal import Decimal
from typing import List, Optional
@@ -14,7 +14,7 @@ from django.db.models.functions import Greatest
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _
from pretix.api.models import OAuthApplication
@@ -28,16 +28,18 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
from pretix.base.models.orders import (
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, OrderRefund,
generate_position_secret, generate_secret,
InvoiceAddress, OrderFee, OrderRefund, generate_position_secret,
generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TaxedPrice
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.mail import SendMailException
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
@@ -160,7 +162,7 @@ def mark_order_expired(order, user=None, auth=None):
return order
def approve_order(order, user=None, send_mail: bool=True, auth=None):
def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False):
"""
Mark this order as approved
:param order: The order to change
@@ -183,7 +185,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None):
fee=None
)
try:
p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth)
p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth, ignore_date=True, force=force)
except Quota.QuotaExceededException:
raise OrderError(error_messages['unavailable'])
@@ -402,6 +404,16 @@ def _check_date(event: Event, now_dt: datetime):
if event.presale_has_ended:
raise OrderError(error_messages['ended'])
if not event.has_subevents:
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(event).date(),
time(hour=23, minute=59, second=59)
), event.timezone)
if term_last < now_dt:
raise OrderError(error_messages['ended'])
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None):
err = None
@@ -456,6 +468,18 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
break
if cp.subevent:
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(cp.subevent).date(),
time(hour=23, minute=59, second=59)
), event.timezone)
if term_last < now_dt:
err = err or error_messages['some_subevent_ended']
delete(cp)
break
if cp.subevent and cp.subevent.presale_has_ended:
err = err or error_messages['some_subevent_ended']
delete(cp)
@@ -567,6 +591,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
meta_info: dict=None, sales_channel: str='web'):
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
p = None
with transaction.atomic():
order = Order(
@@ -600,7 +625,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
fee.save()
if payment_provider and not order.require_approval:
order.payments.create(
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=payment_provider.identifier,
amount=total,
@@ -616,7 +641,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
order.log_action('pretix.event.order.consent', data={'msg': msg})
order_placed.send(event, order=order)
return order
return order, p
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
@@ -640,16 +665,32 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
except InvoiceAddress.DoesNotExist:
pass
with event.lock() as now_dt:
positions = list(CartPosition.objects.filter(
id__in=position_ids).select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons'))
positions = CartPosition.objects.filter(id__in=position_ids, event=event)
lockfn = NoLockManager
locked = False
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2))).exists():
# Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date,
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
locked = True
lockfn = event.lock
with lockfn() as now_dt:
positions = list(positions.select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons'))
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)
order = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=addr, meta_info=meta_info, 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)
free_order_flow = payment and payment_provider == 'free' and order.total == Decimal('0.00') and not order.require_approval
if free_order_flow:
try:
payment.confirm(send_mail=False, lock=not locked)
except Quota.QuotaExceededException:
pass
invoice = order.invoices.last() # Might be generated by plugin already
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
@@ -664,7 +705,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if order.require_approval:
email_template = event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
elif payment_provider == 'free':
elif free_order_flow:
email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
else:
@@ -840,8 +881,8 @@ class OrderChangeManager:
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
'subevent_required': _('You need to choose a subevent for the new position.'),
}
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent', 'price'))
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
@@ -852,6 +893,7 @@ class OrderChangeManager:
self.order = order
self.user = user
self.auth = auth
self.event = order.event
self.split_order = None
self._committed = False
self._totaldiff = 0
@@ -860,33 +902,18 @@ class OrderChangeManager:
self.notify = notify
self._invoice_dirty = False
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation], keep_price=False):
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation'])
if keep_price:
price = TaxedPrice(gross=position.price, net=position.price - position.tax_value,
tax=position.tax_value, rate=position.tax_rate,
name=position.tax_rule.name if position.tax_rule else None)
else:
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
invoice_address=self._invoice_address)
if price is None: # NOQA
raise OrderError(self.error_messages['product_invalid'])
new_quotas = (variation.quotas.filter(subevent=position.subevent)
if variation else item.quotas.filter(subevent=position.subevent))
if not new_quotas:
raise OrderError(self.error_messages['quota_missing'])
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff += price.gross - position.price
self._quotadiff.update(new_quotas)
self._quotadiff.subtract(position.quotas)
self._operations.append(self.ItemOperation(position, item, variation, price))
self._operations.append(self.ItemOperation(position, item, variation))
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
@@ -900,19 +927,15 @@ class OrderChangeManager:
if not new_quotas:
raise OrderError(self.error_messages['quota_missing'])
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff += price.gross - position.price
self._quotadiff.update(new_quotas)
self._quotadiff.subtract(position.quotas)
self._operations.append(self.SubeventOperation(position, subevent, price))
self._operations.append(self.SubeventOperation(position, subevent))
def regenerate_secret(self, position: OrderPosition):
self._operations.append(self.RegenerateSecretOperation(position))
def change_price(self, position: OrderPosition, price: Decimal):
price = position.item.tax(price)
price = position.item.tax(price, base_price_is='gross')
self._totaldiff += price.gross - position.price
@@ -1069,14 +1092,11 @@ class OrderChangeManager:
'new_variation': op.variation.pk if op.variation else None,
'old_price': op.position.price,
'addon_to': op.position.addon_to_id,
'new_price': op.price.gross
'new_price': op.position.price
})
op.position.item = op.item
op.position.variation = op.variation
op.position.price = op.price.gross
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.item.tax_rule
op.position._calculate_tax()
op.position.save()
elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
@@ -1085,13 +1105,9 @@ class OrderChangeManager:
'old_subevent': op.position.subevent.pk,
'new_subevent': op.subevent.pk,
'old_price': op.position.price,
'new_price': op.price.gross
'new_price': op.position.price
})
op.position.subevent = op.subevent
op.position.price = op.price.gross
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.position.item.tax_rule
op.position.save()
elif isinstance(op, self.PriceOperation):
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
@@ -1102,9 +1118,7 @@ class OrderChangeManager:
'new_price': op.price.gross
})
op.position.price = op.price.gross
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.position.item.tax_rule
op.position._calculate_tax()
op.position.save()
elif isinstance(op, self.CancelOperation):
for opa in op.position.addons.all():
@@ -1154,8 +1168,8 @@ class OrderChangeManager:
elif isinstance(op, self.RegenerateSecretOperation):
op.position.secret = generate_position_secret()
op.position.save()
CachedTicket.objects.filter(order_position__order=self.order).delete()
CachedCombinedTicket.objects.filter(order=self.order).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.order.pk})
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
@@ -1388,11 +1402,11 @@ class OrderChangeManager:
order_changed.send(self.order.event, order=self.order)
def _clear_tickets_cache(self):
CachedTicket.objects.filter(order_position__order=self.order).delete()
CachedCombinedTicket.objects.filter(order=self.order).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.order.pk})
if self.split_order:
CachedTicket.objects.filter(order_position__order=self.split_order).delete()
CachedCombinedTicket.objects.filter(order=self.split_order).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.split_order.pk})
def _get_payment_provider(self):
lp = self.order.payments.last()

View File

@@ -123,8 +123,10 @@ def get_tickets_for_order(order):
order=order, provider=p.identifier, file__isnull=False
).last()
if not ct or not ct.file:
retval = generate.apply(args=('order', order.pk, p.identifier))
ct = CachedCombinedTicket.objects.get(pk=retval.get())
retval = generate_order(order.pk, p.identifier)
if not retval:
continue
ct = CachedCombinedTicket.objects.get(pk=retval)
tickets.append((
"{}-{}-{}{}".format(
order.event.slug.upper(), order.code, ct.provider, ct.extension,
@@ -140,8 +142,10 @@ def get_tickets_for_order(order):
order_position=pos, provider=p.identifier, file__isnull=False
).last()
if not ct or not ct.file:
retval = generate.apply(args=('orderposition', pos.pk, p.identifier))
ct = CachedTicket.objects.get(pk=retval.get())
retval = generate_orderposition(pos.pk, p.identifier)
if not retval:
continue
ct = CachedCombinedTicket.objects.get(pk=retval)
tickets.append((
"{}-{}-{}-{}{}".format(
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
@@ -152,3 +156,26 @@ def get_tickets_for_order(order):
logger.exception('Failed to generate ticket.')
return tickets
@app.task(base=ProfiledTask)
def invalidate_cache(event: int, item: int=None, provider: str=None, order: int=None, **kwargs):
event = Event.objects.get(id=event)
qs = CachedTicket.objects.filter(order_position__order__event=event)
qsc = CachedCombinedTicket.objects.filter(order__event=event)
if item:
qs = qs.filter(order_position__item_id=item)
if provider:
qs = qs.filter(provider=provider)
qsc = qsc.filter(provider=provider)
if order:
qs = qs.filter(order_position__order_id=order)
qsc = qsc.filter(order_id=order)
for ct in qs:
ct.delete()
for ct in qsc:
ct.delete()

View File

@@ -1,6 +1,6 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% load static %}
{% block title %}{% trans "Bad Request" %}{% endblock %}
{% block content %}
<i class="fa fa-frown-o fa-fw big-icon"></i>

View File

@@ -1,6 +1,6 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% load static %}
{% block title %}{% trans "Permission denied" %}{% endblock %}
{% block content %}
<i class="fa fa-fw fa-lock big-icon"></i>

View File

@@ -1,6 +1,6 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% load static %}
{% block title %}{% trans "Not found" %}{% endblock %}
{% block content %}
<i class="fa fa-meh-o fa-fw big-icon"></i>

View File

@@ -1,6 +1,6 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% load static %}
{% block title %}{% trans "Internal Server Error" %}{% endblock %}
{% block content %}
<i class="fa fa-bolt big-icon fa-fw"></i>

View File

@@ -1,6 +1,6 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% load static %}
{% block title %}{% trans "Verification failed" %}{% endblock %}
{% block content %}
<i class="fa fa-frown-o big-icon fa-fw"></i>

View File

@@ -1,66 +0,0 @@
{% load i18n %}{% load static from staticfiles %}
<link rel="stylesheet" href="{% static 'debug_toolbar/css/print.css' %}" type="text/css" media="print" />
<link rel="stylesheet" href="{% static 'debug_toolbar/css/toolbar.css' %}" type="text/css" />
<!-- Prevent our copy of jQuery from registering as an AMD module on sites that use RequireJS. -->
<script src="{% static 'debug_toolbar/js/jquery_pre.js' %}"></script>
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
<script src="{% static 'debug_toolbar/js/jquery_post.js' %}"></script>
<script src="{% static 'debug_toolbar/js/toolbar.js' %}"></script>
<div id="djDebug" class="djdt-hidden" dir="ltr"
data-store-id="{{ toolbar.store_id }}" data-render-panel-url="{% url 'djdt:render_panel' %}"
{{ toolbar.config.ROOT_TAG_EXTRA_ATTRS|safe }}>
<div class="djdt-hidden" id="djDebugToolbar">
<ul id="djDebugPanelList">
{% if toolbar.panels %}
<li><a id="djHideToolBarButton" href="#" title="{% trans "Hide toolbar" %}">{% trans "Hide" %} &#187;</a></li>
{% else %}
<li id="djDebugButton">DEBUG</li>
{% endif %}
{% for panel in toolbar.panels %}
<li class="djDebugPanelButton">
<input type="checkbox" data-cookie="djdt{{ panel.panel_id }}" {% if panel.enabled %}checked="checked" title="{% trans "Disable for next and successive requests" %}"{% else %}title="{% trans "Enable for next and successive requests" %}"{% endif %} />
{% if panel.has_content and panel.enabled %}
<a href="#" title="{{ panel.title }}" class="{{ panel.panel_id }}">
{% else %}
<div class="djdt-contentless{% if not panel.enabled %} djdt-disabled{% endif %}">
{% endif %}
{{ panel.nav_title }}
{% if panel.enabled %}
{% with panel.nav_subtitle as subtitle %}
{% if subtitle %}<br /><small>{{ subtitle }}</small>{% endif %}
{% endwith %}
{% endif %}
{% if panel.has_content and panel.enabled %}
</a>
{% else %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div class="djdt-hidden" id="djDebugToolbarHandle">
<span title="{% trans "Show toolbar" %}" id="djShowToolBarButton">&#171;</span>
</div>
{% for panel in toolbar.panels %}
{% if panel.has_content and panel.enabled %}
<div id="{{ panel.panel_id }}" class="djdt-panelContent">
<div class="djDebugPanelTitle">
<a href="" class="djDebugClose"></a>
<h3>{{ panel.title|safe }}</h3>
</div>
<div class="djDebugPanelContent">
{% if toolbar.store_id %}
<img src="{% static 'debug_toolbar/img/ajax-loader.gif' %}" alt="loading" class="djdt-loader" />
<div class="djdt-scroll"></div>
{% else %}
<div class="djdt-scroll">{{ panel.content }}</div>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
<div id="djDebugWindow" class="djdt-panelContent"></div>
</div>

View File

@@ -1,6 +1,6 @@
{% load compress %}
{% load i18n %}
{% load staticfiles %}
{% load static %}
<!DOCTYPE html>
<html>
<head>

View File

@@ -0,0 +1,6 @@
{% load thumb %}
{% if widget.is_initial %}{{ widget.initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}">
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}{% if widget.value.is_img %}<br><a href="{{ widget.value.url }}" data-lightbox="{{ widget.value.name }}"><img src="{{ widget.value|thumb:"200x100" }}" /></a>{% endif %}<br>
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>

View File

@@ -17,6 +17,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
if not arg:
raise ValueError("No currency passed.")
arg = arg.upper()
places = settings.CURRENCY_PLACES.get(arg, 2)
rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)

View File

@@ -72,9 +72,11 @@ def safelink_callback(attrs, new=False):
def abslink_callback(attrs, new=False):
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, attrs.get((None, 'href'), '/'))
attrs[None, 'target'] = '_blank'
attrs[None, 'rel'] = 'noopener'
url = attrs.get((None, 'href'), '/')
if not url.startswith('mailto:') and not url.startswith('tel:'):
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
attrs[None, 'target'] = '_blank'
attrs[None, 'rel'] = 'noopener'
return attrs
@@ -90,7 +92,7 @@ def markdown_compile_email(source):
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS,
))
), parse_email=True)
def markdown_compile(source):
@@ -116,6 +118,7 @@ def rich_text(text: str, **kwargs):
text = str(text)
body_md = bleach.linkify(
markdown_compile(text),
callbacks=DEFAULT_CALLBACKS + ([safelink_callback] if kwargs.get('safelinks', True) else [abslink_callback])
callbacks=DEFAULT_CALLBACKS + ([safelink_callback] if kwargs.get('safelinks', True) else [abslink_callback]),
parse_email=True
)
return mark_safe(body_md)

View File

@@ -56,7 +56,9 @@ def page_not_found(request, exception):
}
template = get_template('404.html')
body = template.render(context, request)
return HttpResponseNotFound(body)
r = HttpResponseNotFound(body)
r.xframe_options_exempt = True
return r
@requires_csrf_token
@@ -65,7 +67,9 @@ def server_error(request):
template = loader.get_template('500.html')
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
return HttpResponseServerError(template.render({
r = HttpResponseServerError(template.render({
'request': request,
'sentry_event_id': last_event_id(),
}))
r.xframe_options_exempt = True
return r

View File

@@ -2,7 +2,7 @@ from django.utils import timezone
from django.utils.translation.trans_real import DjangoTranslation
from django.views.decorators.cache import cache_page
from django.views.decorators.http import etag
from django.views.i18n import JavaScriptCatalog, render_javascript_catalog
from django.views.i18n import JavaScriptCatalog
# Yes, we want to regenerate this every time the module has been imported to
# refresh the cache at least at every code deployment
@@ -21,4 +21,5 @@ js_info_dict = {
def js_catalog(request, lang):
c = JavaScriptCatalog()
c.translation = DjangoTranslation(lang, domain='djangojs')
return render_javascript_catalog(c.get_catalog(), c.get_plural())
context = c.get_context_data()
return c.render_to_response(context)

View File

@@ -20,10 +20,10 @@ def serve_metrics(request):
return unauthed_response()
# check if the user is properly authorized:
if "HTTP_AUTHORIZATION" not in request.META:
if "Authorization" not in request.headers:
return unauthed_response()
method, credentials = request.META["HTTP_AUTHORIZATION"].split(" ", 1)
method, credentials = request.headers["Authorization"].split(" ", 1)
if method.lower() != "basic":
return unauthed_response()

View File

@@ -133,7 +133,7 @@ class AsyncAction:
return str(exception)
else:
logger.error('Unexpected exception: %r' % exception)
return _('An unexpected error has occurred.')
return _('An unexpected error has occurred, please try again later.')
def get_success_message(self, value):
return _('The task has been completed.')

View File

@@ -4,8 +4,8 @@ import os
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files import File
from django.forms.utils import from_current_timezone
from django.utils.html import conditional_escape
from django.utils.translation import ugettext_lazy as _
from ...base.forms import I18nModelForm
@@ -67,16 +67,31 @@ def selector(values, prop):
class ClearableBasenameFileInput(forms.ClearableFileInput):
template_name = 'pretixbase/forms/widgets/thumbnailed_file_input.html'
def get_template_substitution_values(self, value):
"""
Return value-related substitutions.
"""
bname = os.path.basename(value.name)
return {
'initial': conditional_escape(bname),
'initial_url': conditional_escape(value.url),
}
class FakeFile(File):
def __init__(self, file):
self.file = file
@property
def name(self):
return self.file.name
@property
def is_img(self):
return any(self.file.name.endswith(e) for e in ('.jpg', '.jpeg', '.png', '.gif'))
def __str__(self):
return os.path.basename(self.file.name).split('.', 1)[-1]
@property
def url(self):
return self.file.url
def get_context(self, name, value, attrs):
ctx = super().get_context(name, value, attrs)
ctx['widget']['value'] = self.FakeFile(value)
return ctx
class ExtFileField(forms.FileField):

View File

@@ -279,7 +279,7 @@ class EventSettingsForm(SettingsForm):
)
display_net_prices = forms.BooleanField(
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
help_text=_("Independent of your choice, the cart will show gross prices as this the price that needs to be "
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
"paid"),
required=False
)

View File

@@ -82,6 +82,7 @@ class QuestionForm(I18nModelForm):
'type',
'required',
'ask_during_checkin',
'hidden',
'identifier',
'items',
'dependency_question',
@@ -463,6 +464,7 @@ class ItemVariationForm(I18nModelForm):
'value',
'active',
'default_price',
'original_price',
'description',
]

View File

@@ -9,9 +9,7 @@ from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.models import (
InvoiceAddress, Item, ItemAddOn, Order, OrderPosition,
)
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
@@ -150,15 +148,6 @@ class CommentForm(I18nModelForm):
}
class SubEventChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
p = get_price(self.instance.item, self.instance.variation,
voucher=self.instance.voucher,
subevent=obj)
return '{} {} ({})'.format(obj.name, obj.get_date_range_display(),
p.print(self.instance.order.event.currency))
class OtherOperationsForm(forms.Form):
recalculate_taxes = forms.BooleanField(
label=_('Re-calculate taxes'),
@@ -265,12 +254,13 @@ class OrderPositionAddForm(forms.Form):
class OrderPositionChangeForm(forms.Form):
itemvar = forms.ChoiceField()
subevent = SubEventChoiceField(
itemvar = forms.ChoiceField(
required=False,
)
subevent = forms.ModelChoiceField(
SubEvent.objects.none(),
label=pgettext_lazy('subevent', 'New date'),
required=True,
empty_label=None
required=False,
empty_label=_('(Unchanged)')
)
price = forms.DecimalField(
required=False,
@@ -278,53 +268,49 @@ class OrderPositionChangeForm(forms.Form):
localize=True,
label=_('New price (gross)')
)
operation = forms.ChoiceField(
operation_secret = forms.BooleanField(
required=False,
widget=forms.RadioSelect,
choices=(
('product', 'Change product'),
('price', 'Change price'),
('subevent', 'Change event date'),
('cancel', 'Remove product'),
('split', 'Split into new order'),
('secret', 'Regenerate secret'),
)
label=_('Generate a new secret')
)
operation_cancel = forms.BooleanField(
required=False,
label=_('Cancel this position')
)
operation_split = forms.BooleanField(
required=False,
label=_('Split into new order')
)
change_product_keep_price = forms.BooleanField(required=False)
def __init__(self, *args, **kwargs):
instance = kwargs.pop('instance')
initial = kwargs.get('initial', {})
try:
ia = instance.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
if instance:
try:
if instance.variation:
initial['itemvar'] = '%d-%d' % (instance.item.pk, instance.variation.pk)
elif instance.item:
initial['itemvar'] = str(instance.item.pk)
except Item.DoesNotExist:
pass
if instance.item.tax_rule and not instance.item.tax_rule.price_includes_tax:
initial['price'] = instance.price - instance.tax_value
else:
initial['price'] = instance.price
initial['subevent'] = instance.subevent
if instance.item.tax_rule and not instance.item.tax_rule.price_includes_tax:
initial['price'] = instance.price - instance.tax_value
else:
initial['price'] = instance.price
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if instance.order.event.has_subevents:
self.fields['subevent'].instance = instance
self.fields['subevent'].queryset = instance.order.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': instance.order.event.slug,
'organizer': instance.order.event.organizer.slug,
}),
'data-placeholder': _('(Unchanged)')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
else:
del self.fields['subevent']
choices = []
choices = [
('', _('(Unchanged)'))
]
for i in instance.order.event.items.prefetch_related('variations').all():
pname = str(i)
if not i.is_available():
@@ -333,14 +319,10 @@ class OrderPositionChangeForm(forms.Form):
if variations:
for v in variations:
p = get_price(i, v, voucher=instance.voucher, subevent=instance.subevent,
invoice_address=ia)
choices.append(('%d-%d' % (i.pk, v.pk),
'%s %s (%s)' % (pname, v.value, p.print(instance.order.event.currency))))
'%s %s' % (pname, v.value)))
else:
p = get_price(i, None, voucher=instance.voucher, subevent=instance.subevent,
invoice_address=ia)
choices.append((str(i.pk), '%s (%s)' % (pname, p.print(instance.order.event.currency))))
choices.append((str(i.pk), pname))
self.fields['itemvar'].choices = choices
change_decimal_field(self.fields['price'], instance.order.event.currency)

View File

@@ -29,6 +29,7 @@ class SubEventForm(I18nModelForm):
fields = [
'name',
'active',
'is_public',
'date_from',
'date_to',
'date_admission',

View File

@@ -140,7 +140,7 @@ class VoucherForm(I18nModelForm):
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
cnt = len(data['codes']) * data.get('max_usages', 0)
else:
cnt = data['max_usages']
cnt = data.get('max_usages', 0)
Voucher.clean_item_properties(
data, self.instance.event,

View File

@@ -189,6 +189,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
'pretix.event.order.email.error': _('Sending of an email has failed.'),
'pretix.event.order.email.attachments.skipped': _('The email has been sent without attachments since they '
'would have been too large to be likely to arrive.'),
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
'is available for download.'),
@@ -389,6 +391,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
if logentry.action_type == 'pretix.team.invite.created':
return _('{user} has been invited to the team.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.invite.resent':
return _('Invite for {user} has been resent.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.invite.deleted':
return _('The invite for {user} has been revoked.').format(user=data.get('email'))

View File

@@ -2,16 +2,6 @@ from django.dispatch import Signal
from pretix.base.signals import DeprecatedSignal, EventPluginSignal
restriction_formset = EventPluginSignal(
providing_args=["item"]
)
"""
This signal is sent out to build configuration forms for all restriction formsets
(see plugin API documentation for details).
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
html_page_start = Signal(
providing_args=[]
)

View File

@@ -1,6 +1,6 @@
{% load compress %}
{% load i18n %}
{% load staticfiles %}
{% load static %}
<!DOCTYPE html>
<html>
<head>

View File

@@ -12,6 +12,7 @@
{{ settings.PRETIX_INSTANCE_NAME }}</title>
{% compress css %}
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/main.scss" %}" />
<link rel="stylesheet" type="text/x-scss" href="{% static "lightbox/css/lightbox.scss" %}" />
{% endcompress %}
{% if DEBUG %}
<script type="text/javascript" src="{% url 'javascript-catalog' lang=request.LANGUAGE_CODE %}"
@@ -43,6 +44,7 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/mail.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/orderchange.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/typeahead.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quicksetup.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
@@ -50,6 +52,7 @@
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
<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>
{% endcompress %}
{{ html_head|safe }}
@@ -101,6 +104,11 @@
<i class="fa fa-eye"></i>
</a>
{% endif %}
{% elif request.organizer %}
<a href="{% eventurl request.organizer "presale:organizer.index" %}" title="{% trans "Public profile" %}" target="_blank"
class="navbar-toggle mobile-navbar-view-link">
<i class="fa fa-eye"></i>
</a>
{% endif %}
<a class="navbar-brand" href="{% url "control:index" %}">
<img src="{% static "pretixbase/img/pretix-icon.svg" %}" />
@@ -123,6 +131,12 @@
</a>
{% endif %}
</li>
{% elif request.organizer %}
<li>
<a href="{% eventurl request.organizer "presale:organizer.index" %}" title="{% trans "Public profile" %}" target="_blank">
<i class="fa fa-eye"></i> {% trans "Public profile" %}
</a>
</li>
{% endif %}
</ul>
<ul class="nav navbar-nav navbar-top-links navbar-right">
@@ -366,6 +380,12 @@
{% block content %}
{% endblock %}
<footer>
{% if request.timezone %}
<span class="fa fa-globe"></span>
{% blocktrans trimmed with tz=request.timezone %}
Times displayed in {{ tz }}
{% endblocktrans %} &middot;
{% endif %}
{% with "href='http://pretix.eu'" as a_attr %}
{% blocktrans trimmed %}
powered by <a {{ a_attr }}>pretix</a>

View File

@@ -95,7 +95,15 @@
{% endif %}
</td>
<td>{{ e.item }}{% if e.variation %} {{ e.variation }}{% endif %}</td>
<td>{{ e.order.email }}</td>
<td>
{% if e.addon_to and e.addon_to.attendee_email %}
{{ e.addon_to.attendee_email }}
{% elif e.attendee_email %}
{{ e.attendee_email }}
{% else %}
{{ e.order.email }}
{% endif %}
</td>
<td>
{% if e.addon_to %}
{{ e.addon_to.attendee_name }}

View File

@@ -38,7 +38,10 @@
{% empty %}
<tr>
<td colspan="3">
{% trans "There are no payment providers available. Please go to the plugin settings and activate one or more payment plugins." %}
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
{% blocktrans trimmed with plugin_settings_href='href="'|add:plugin_settings_url|add:'"'|safe %}
There are no payment providers available. Please go to the <a {{ plugin_settings_href }}>plugin settings</a> and activate one or more payment plugins.
{% endblocktrans %}
</td>
</tr>
{% endfor %}

View File

@@ -40,7 +40,10 @@
</div>
{% empty %}
<div class="alert alert-warning">
{% trans "There are no ticket outputs available. Please go to the plugin settings and activate one or more ticket output plugins." %}</em>
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
{% blocktrans trimmed with plugin_settings_href='href="'|add:plugin_settings_url|add:'"'|safe %}
There are no ticket outputs available. Please go to the <a {{ plugin_settings_href }}>plugin settings</a> and activate one or more ticket output plugins.
{% endblocktrans %}
</div>
{% endfor %}
</fieldset>

View File

@@ -106,6 +106,9 @@
{{ e.get_short_date_to_display }}
{% endif %}
{% endif %}
{% if e.settings.timezone != request.timezone %}
<span class="fa fa-globe text-muted" data-toggle="tooltip" title="{{ e.timezone }}"></span>
{% endif %}
</td>
<td>
{% for q in e.first_quotas|slice:":3" %}

View File

@@ -45,6 +45,7 @@
{% bootstrap_form_errors form %}
{% bootstrap_field form.active layout="control" %}
{% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.description layout="control" %}
</div>
</div>
@@ -78,6 +79,7 @@
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.active layout="control" %}
{% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field formset.empty_form.description layout="control" %}
</div>
</div>

View File

@@ -109,6 +109,7 @@
{% bootstrap_field form.help_text layout="control" %}
{% bootstrap_field form.identifier layout="control" %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% bootstrap_field form.hidden layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_dependency_question">

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}
{% blocktrans trimmed with code=order.code %}
Change order: {{ code }}
@@ -71,92 +72,87 @@
</h3>
</div>
<div class="panel-body">
<div class="form-inline form-order-change">
<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 %}">
{% bootstrap_form_errors position.form %}
{% if position.custom_error %}
<div class="alert alert-danger">
{{ position.custom_error }}
</div>
{% endif %}
<p class="help-block">
{% trans "Ticket secret:" %} {{ position.secret|slice:":12" }}…
</p>
<div class="radio">
<label>
<input name="{{ position.form.prefix }}-operation" type="radio" value=""
{% if not position.form.operation.value %}checked="checked"{% endif %}>
{% trans "Keep unchanged" %}
</label>
<div class="row">
<div class="col-sm-5 col-sm-offset-3">
<strong>{% trans "Current value" %}</strong>
</div>
<div class="col-sm-4">
<strong>{% trans "Change to" %}</strong>
</div>
</div>
<div class="radio">
<label>
<input name="{{ position.form.prefix }}-operation" type="radio" value="product"
{% if position.form.operation.value == "product" %}checked="checked"{% endif %}>
{% trans "Change product to" %}
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Product" %}</strong>
</div>
<div class="col-sm-5">
{{ position.item }}
{% if position.variation %}
{{ position.variation }}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.itemvar layout='inline' %}
</label>
<label class="checkbox">
{{ position.form.change_product_keep_price }}
{% trans "Keep price the same" %}
</label>
</div>
</div>
{% if request.event.has_subevents %}
<div class="radio">
<label>
<input name="{{ position.form.prefix }}-operation" type="radio" value="subevent"
{% if position.form.operation.value == "subevent" %}checked="checked"{% endif %}>
{% trans "Change date to" context "subevent" %}
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Date" context "subevent" %}</strong>
</div>
<div class="col-sm-5">
{{ position.subevent }}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.subevent layout='inline' %}
</label>
</div>
</div>
{% endif %}
<div class="radio">
<label>
<input name="{{ position.form.prefix }}-operation" type="radio" value="price"
{% if position.form.operation.value == "price" %}checked="checked"{% endif %}>
{% trans "Change price to" %}
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Price" %}</strong>
</div>
<div class="col-sm-5">
{{ position.price|money:request.event.currency }}<br>
{% if position.tax_rate %}
<small>{% blocktrans trimmed with rate=position.tax_rate name=position.tax_rule.name %}
<strong>incl.</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% endif %}
</div>
<div class="col-sm-4 field-container">
{% bootstrap_field position.form.price addon_after=request.event.currency layout='inline' %}
{% if position.apply_tax %}
{% if position.item.tax_rule and not position.item.tax_rule.price_includes_tax %}
{% blocktrans trimmed with rate=position.item.tax_rule.rate name=position.item.tax_rule.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}
{% elif position.item.tax_rule %}
{% blocktrans trimmed with rate=position.item.tax_rule.rate name=position.item.tax_rule.name %}
<strong>incl.</strong> {{ rate }}% {{ name }}
{% endblocktrans %}
{% endif %}
{% else %}
{% trans "no taxes apply" %}
{% endif %}
</label>
<small><strong>{% trans "including all taxes" %}</strong></small>
</div>
</div>
<div class="radio">
<label>
<input name="{{ position.form.prefix }}-operation" type="radio" value="split"
{% if position.form.operation.value == "split" %}checked="checked"{% endif %}>
{% trans "Split into new order" %}
</label>
</div>
<div class="radio">
<label>
<input name="{{ position.form.prefix }}-operation" type="radio" value="secret"
{% if position.form.operation.value == "secret" %}checked="checked"{% endif %}>
{% trans "Generate a new secret" %}
</label>
</div>
<div class="radio">
<label>
<input name="{{ position.form.prefix }}-operation" type="radio" value="cancel"
{% if position.form.operation.value == "cancel" %}checked="checked"{% endif %}>
{% trans "Cancel position" %}
{% if position.addons.exists %}
<em class="text-danger">
{% trans "Removing this position will also remove all add-ons to this position." %}
</em>
{% endif %}
</label>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Ticket secret" %}</strong>
</div>
<div class="col-sm-5">
{{ position.secret|slice:":12" }}…
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.operation_secret layout='inline' %}
</div>
</div>
{% bootstrap_field position.form.operation_cancel layout='inline' %}
{% bootstrap_field position.form.operation_split layout='inline' %}
{% if position.addons.exists %}
<em class="text-danger">
{% trans "Removing this position will also remove all add-ons to this position." %}
</em>
{% endif %}
</div>
</div>
</div>

View File

@@ -118,7 +118,7 @@
<dt>{% trans "Order code" %}</dt>
<dd>{{ order.code }}</dd>
<dt>{% trans "Order date" %}</dt>
<dd>{{ order.datetime }}</dd>
<dd>{{ order.datetime|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% if sales_channel %}
<dt>{% trans "Sales channel" %}</dt>
<dd>{{ sales_channel.verbose_name }}</dd>
@@ -132,7 +132,7 @@
</dd>
{% if order.status == "n" %}
<dt>{% trans "Expiry date" %}</dt>
<dd>{{ order.expires }}</dd>
<dd>{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
<dt>{% trans "User" %}</dt>
<dd>
@@ -489,7 +489,27 @@
{% if p.html_info %}
<tr>
<td colspan="1"></td>
<td colspan="5">{{ p.html_info|safe }}</td>
<td colspan="5">
{{ p.html_info|safe }}
{% if staff_session %}
<p>
<a href="" class="btn btn-default btn-xs" data-expandpayment data-id="{{ p.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="5">
<a href="" class="btn btn-default btn-xs" data-expandpayment data-id="{{ p.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>
</td>
</tr>
{% endif %}
{% endfor %}
@@ -574,6 +594,18 @@
{% endif %}
</td>
</tr>
{% if staff_session %}
<tr>
<td colspan="1"></td>
<td colspan="7">
<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>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View File

@@ -3,8 +3,16 @@
{% load bootstrap3 %}
{% block inner %}
<h1>
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
{% blocktrans with name=request.organizer.name %}Organizer: {{ name }}{% endblocktrans %}
</h1>
{% if "can_create_events" in request.orgapermset %}
<p>
<a href="{% url "control:events.add" %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new event" %}
</a>
</p>
{% endif %}
{% if events|length == 0 %}
<p>
<em>{% trans "You currently do not have access to any events." %}</em>
@@ -14,7 +22,12 @@
<thead>
<tr>
<th>{% trans "Event name" %}</th>
<th>{% trans "Start date" %}</th>
<th>
{% trans "Start date" %}
/
{% trans "End date" %}
</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
@@ -25,16 +38,47 @@
<strong><a
href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
</td>
<td>{{ e.get_date_from_display }}</td>
<td>
{% if e.has_subevents %}
{{ e.min_from|default_if_none:""|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
{{ e.get_short_date_from_display }}
{% endif %}
{% if e.has_subevents %}
<span class="label label-default">{% trans "Series" %}</span>
{% endif %}
{% if e.settings.show_date_to and e.date_to %}
<br>
{% if e.has_subevents %}
{{ e.max_fromto|default_if_none:e.max_from|default_if_none:e.max_to|default_if_none:""|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
{{ e.get_short_date_to_display }}
{% endif %}
{% endif %}
{% if e.settings.timezone != request.timezone %}
<span class="fa fa-globe text-muted" data-toggle="tooltip" title="{{ e.timezone }}"></span>
{% endif %}
</td>
<td>
{% if not e.live %}
<span class="label label-danger">{% trans "Shop disabled" %}</span>
{% elif e.presale_has_ended %}
<span class="label label-warning">{% trans "Presale over" %}</span>
{% elif not e.presale_is_running %}
<span class="label label-warning">{% trans "Presale not started" %}</span>
{% else %}
<span class="label label-success">{% trans "On sale" %}</span>
{% endif %}
</td>
<td class="text-right">
<a href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}"
class="btn btn-sm btn-default" title="{% trans "Open event dashboard" %}"
data-toggle="tooltip">
class="btn btn-sm btn-default" title="{% trans "Open event dashboard" %}"
data-toggle="tooltip">
<span class="fa fa-eye"></span>
</a>
{% if "can_create_events" in request.orgapermset %}
<a href="{% url "control:events.add" %}?clone={{ e.pk }}" class="btn btn-sm btn-default"
title="{% trans "Clone event" %}" data-toggle="tooltip">
title="{% trans "Clone event" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
{% endif %}
@@ -43,11 +87,6 @@
{% endfor %}
</tbody>
</table>
{% endif %}
{% if "can_create_events" in request.orgapermset %}
<a href="{% url "control:events.add" %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new event" %}
</a>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load staticfiles %}
{% load static %}
{% load bootstrap3 %}
{% block inner %}
<h1>{% trans "Connect to device:" %} {{ device.name }}</h1>

View File

@@ -51,6 +51,11 @@
{{ i.email }}
<span class="fa fa-envelope-o" data-toggle="tooltip"
title="{% trans "invited, pending response" %}"></span>
<button type="submit" name="resend-invite" value="{{ i.id }}"
data-toggle="tooltip" title="{% trans "resend invite" %}"
class="btn-invisible">
<span class="fa fa-repeat"></span>
</button>
</td>
<td class="text-right">
<button type="submit" name="remove-invite" value="{{ i.id }}"

View File

@@ -269,6 +269,7 @@
{% bootstrap_field form.location layout="control" %}
{% bootstrap_field form.time_admission layout="control" %}
{% bootstrap_field form.frontpage_text layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% if meta_forms %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>

View File

@@ -27,6 +27,7 @@
{% bootstrap_field form.location layout="control" %}
{% bootstrap_field form.date_admission layout="control" %}
{% bootstrap_field form.frontpage_text layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% if meta_forms %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>

View File

@@ -19,6 +19,8 @@ urlpatterns = [
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'),
url(r'^global/message/$', global_settings.MessageView.as_view(), name='global.message'),
url(r'^logdetail/$', global_settings.LogDetailView.as_view(), name='global.logdetail'),
url(r'^logdetail/payment/$', global_settings.PaymentDetailView.as_view(), name='global.paymentdetail'),
url(r'^logdetail/refund/$', global_settings.RefundDetailView.as_view(), name='global.refunddetail'),
url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'),
url(r'^sudo/$', user.StartStaffSession.as_view(), name='user.sudo'),
url(r'^sudo/stop/$', user.StopStaffSession.as_view(), name='user.sudo.stop'),

View File

@@ -100,6 +100,7 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
'list': self.list.pk,
'web': True
}, user=request.user)
op.order.touch()
messages.success(request, _('The selected check-ins have been reverted.'))
else:
@@ -247,7 +248,7 @@ class CheckinListDelete(EventPermissionRequiredMixin, DeleteView):
self.object = self.get_object()
success_url = self.get_success_url()
self.object.checkins.all().delete()
self.object.log_action(action='pretix.event.orders.deleted', user=request.user)
self.object.log_action(action='pretix.event.checkinlists.deleted', user=request.user)
self.object.delete()
messages.success(self.request, _('The selected list has been deleted.'))
return HttpResponseRedirect(success_url)

View File

@@ -360,7 +360,8 @@ def widgets_for_event_qs(request, qs, user, nmax):
'_settings_objects', 'organizer___settings_objects'
).select_related('organizer')[:nmax]
for event in events:
tz = pytz.timezone(event.cache.get_or_set('timezone', lambda: event.settings.timezone))
tzname = event.cache.get_or_set('timezone', lambda: event.settings.timezone)
tz = pytz.timezone(tzname)
if event.has_subevents:
if event.min_from is None:
dr = pgettext("subevent", "No dates")
@@ -393,6 +394,9 @@ def widgets_for_event_qs(request, qs, user, nmax):
((date_format(event.date_admission.astimezone(tz), 'TIME_FORMAT') + ' / ')
if event.date_admission and event.date_admission != event.date_from else '')
+ (date_format(event.date_from.astimezone(tz), 'TIME_FORMAT') if event.date_from else '')
) + (
' <span class="fa fa-globe text-muted" data-toggle="tooltip" title="{}"></span>'.format(tzname)
if tzname != request.timezone and not event.has_subevents else ''
),
url=reverse('control:event.index', kwargs={
'event': event.slug,

View File

@@ -31,8 +31,7 @@ from pytz import timezone
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import LazyCurrencyNumber
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, LogEntry, Order, RequiredAction,
TaxRule, Voucher,
Event, LogEntry, Order, RequiredAction, TaxRule, Voucher,
)
from pretix.base.models.event import EventMetaValue
from pretix.base.services import tickets
@@ -789,12 +788,7 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
for k in provider.form.changed_data
}
)
CachedTicket.objects.filter(
order_position__order__event=self.request.event, provider=provider.identifier
).delete()
CachedCombinedTicket.objects.filter(
order__event=self.request.event, provider=provider.identifier
).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'provider': provider.identifier})
else:
success = False
form = self.get_form(self.get_form_class())

View File

@@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _
from django.views import View
from django.views.generic import FormView, TemplateView
from pretix.base.models import LogEntry
from pretix.base.models import LogEntry, OrderPayment, OrderRefund
from pretix.base.services.update_check import check_result_table, update_check
from pretix.base.settings import GlobalSettingsObject
from pretix.control.forms.global_settings import (
@@ -71,3 +71,15 @@ class LogDetailView(AdministratorPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs):
le = get_object_or_404(LogEntry, pk=request.GET.get('pk'))
return JsonResponse({'data': le.parsed_data})
class PaymentDetailView(AdministratorPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs):
p = get_object_or_404(OrderPayment, pk=request.GET.get('pk'))
return JsonResponse({'data': p.info_data})
class RefundDetailView(AdministratorPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs):
p = get_object_or_404(OrderRefund, pk=request.GET.get('pk'))
return JsonResponse({'data': p.info_data})

View File

@@ -15,14 +15,16 @@ from django.views.generic import ListView
from django.views.generic.base import TemplateView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.edit import DeleteView
from django_countries.fields import Country
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CachedTicket, CartPosition, Item, ItemCategory, ItemVariation, Order,
Question, QuestionAnswer, QuestionOption, Quota, Voucher,
CartPosition, Item, ItemCategory, ItemVariation, Order, Question,
QuestionAnswer, QuestionOption, Quota, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle
from pretix.base.services.tickets import invalidate_cache
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemUpdateForm, ItemVariationForm,
@@ -49,7 +51,9 @@ class ItemList(ListView):
event=self.request.event
).annotate(
var_count=Count('variations')
).prefetch_related("category")
).prefetch_related("category").order_by(
'category__position', 'category', 'position'
)
def item_move(request, item, up=True):
@@ -441,6 +445,10 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
a['alink'] = a['answer']
a['answer'] = ugettext('Yes') if a['answer'] == 'True' else ugettext('No')
a['answer_bool'] = a['answer'] == 'True'
elif self.object.type == Question.TYPE_COUNTRYCODE:
for a in qs:
a['alink'] = a['answer']
a['answer'] = Country(a['answer']).name or a['answer']
return list(qs)
@@ -873,7 +881,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
self.object.log_action(
'pretix.event.item.changed', user=self.request.user, data=data
)
CachedTicket.objects.filter(order_position__item=self.item).delete()
invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'item': self.object.pk})
for f in self.plugin_forms:
f.save()
return super().form_valid(form)

View File

@@ -2,6 +2,7 @@ import json
import logging
import mimetypes
import os
import re
from datetime import timedelta
from decimal import Decimal, DecimalException
@@ -43,6 +44,7 @@ from pretix.base.models.orders import (
)
from pretix.base.models.tax import EU_COUNTRIES
from pretix.base.payment import PaymentException
from pretix.base.services import tickets
from pretix.base.services.export import export
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task,
@@ -1239,7 +1241,11 @@ class OrderChange(OrderView):
return False
try:
if p.form.cleaned_data['operation'] == 'product':
if p.form.cleaned_data['operation_cancel']:
ocm.cancel(p)
continue
if p.form.cleaned_data['itemvar']:
if '-' in p.form.cleaned_data['itemvar']:
itemid, varid = p.form.cleaned_data['itemvar'].split('-')
else:
@@ -1250,16 +1256,19 @@ class OrderChange(OrderView):
variation = ItemVariation.objects.get(pk=varid, item=item)
else:
variation = None
ocm.change_item(p, item, variation, keep_price=p.form.cleaned_data['change_product_keep_price'])
elif p.form.cleaned_data['operation'] == 'price':
ocm.change_price(p, p.form.cleaned_data['price'])
elif p.form.cleaned_data['operation'] == 'subevent':
if item != p.item or variation != p.variation:
ocm.change_item(p, item, variation)
if self.request.event.has_subevents and p.form.cleaned_data['subevent'] and p.form.cleaned_data['subevent'] != p.subevent:
ocm.change_subevent(p, p.form.cleaned_data['subevent'])
elif p.form.cleaned_data['operation'] == 'cancel':
ocm.cancel(p)
elif p.form.cleaned_data['operation'] == 'split':
if p.form.cleaned_data['price'] != p.price:
ocm.change_price(p, p.form.cleaned_data['price'])
if p.form.cleaned_data['operation_split']:
ocm.split(p)
elif p.form.cleaned_data['operation'] == 'secret':
if p.form.cleaned_data['operation_secret']:
ocm.regenerate_secret(p)
except OrderError as e:
@@ -1319,8 +1328,7 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView):
'you need to do this manually.')
messages.success(self.request, _(success_message))
CachedTicket.objects.filter(order_position__order=self.order).delete()
CachedCombinedTicket.objects.filter(order=self.order).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'order': self.order.pk})
order_modified.send(sender=self.request.event, order=self.order)
return redirect(self.get_order_url())
@@ -1363,8 +1371,7 @@ class OrderContactChange(OrderView):
for op in self.order.all_positions.all():
op.secret = generate_position_secret()
op.save()
CachedTicket.objects.filter(order_position__order=self.order).delete()
CachedCombinedTicket.objects.filter(order=self.order).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'order': self.order.pk})
self.order.log_action('pretix.event.order.secret.changed', user=self.request.user)
self.form.save()
@@ -1405,6 +1412,7 @@ class OrderLocaleChange(OrderView):
)
self.form.save()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'order': self.order.pk})
messages.success(self.request, _('The order has been changed.'))
return redirect(self.get_order_url())
return self.get(*args, **kwargs)
@@ -1585,6 +1593,10 @@ class OrderGo(EventPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs):
code = request.GET.get("code", "").upper().strip()
if '://' in code:
m = re.match('.*/ORDER/([A-Z0-9]{' + str(settings.ENTROPY['order_code']) + '})/.*', code)
if m:
code = m.group(1)
try:
if code.startswith(request.event.slug.upper()):
code = code[len(request.event.slug):]

View File

@@ -6,7 +6,8 @@ from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.db import transaction
from django.db.models import Count, ProtectedError
from django.db.models import Count, Max, Min, ProtectedError
from django.db.models.functions import Coalesce, Greatest
from django.forms import inlineformset_factory
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
@@ -19,7 +20,7 @@ from django.views.generic import (
from pretix.api.models import WebHook
from pretix.base.models import Device, Organizer, Team, TeamInvite, User
from pretix.base.models.event import EventMetaProperty
from pretix.base.models.event import Event, EventMetaProperty
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.services.mail import SendMailException, mail
from pretix.control.forms.filter import OrganizerFilterForm
@@ -86,18 +87,33 @@ class OrganizerDetailViewMixin:
return self.request.organizer
class OrganizerDetail(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
model = Organizer
class OrganizerDetail(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = Event
template_name = 'pretixcontrol/organizers/detail.html'
permission = None
context_object_name = 'organizer'
context_object_name = 'events'
def get_object(self, queryset=None) -> Organizer:
@property
def organizer(self):
return self.request.organizer
def get_queryset(self):
qs = self.request.user.get_events_with_any_permission(self.request).select_related('organizer').prefetch_related(
'_settings_objects', 'organizer___settings_objects'
).filter(organizer=self.request.organizer).order_by('-date_from')
qs = qs.annotate(
min_from=Min('subevents__date_from'),
max_from=Max('subevents__date_from'),
max_to=Max('subevents__date_to'),
max_fromto=Greatest(Max('subevents__date_to'), Max('subevents__date_from'))
).annotate(
order_from=Coalesce('min_from', 'date_from'),
order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to', 'date_from'),
)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['events'] = self.request.organizer.events.all()
return ctx
@@ -518,7 +534,7 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
if 'remove-member' in request.POST:
try:
user = User.objects.get(pk=request.POST.get('remove-member'))
except User.DoesNotExist:
except (User.DoesNotExist, ValueError):
pass
else:
other_admin_teams = self.request.organizer.teams.exclude(pk=self.object.pk).filter(
@@ -542,7 +558,7 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
elif 'remove-invite' in request.POST:
try:
invite = self.object.invites.get(pk=request.POST.get('remove-invite'))
except TeamInvite.DoesNotExist:
except (TeamInvite.DoesNotExist, ValueError):
messages.error(self.request, _('Invalid invite selected.'))
return redirect(self.get_success_url())
else:
@@ -555,10 +571,26 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
messages.success(self.request, _('The invite has been revoked.'))
return redirect(self.get_success_url())
elif 'resend-invite' in request.POST:
try:
invite = self.object.invites.get(pk=request.POST.get('resend-invite'))
except (TeamInvite.DoesNotExist, ValueError):
messages.error(self.request, _('Invalid invite selected.'))
return redirect(self.get_success_url())
else:
self._send_invite(invite)
self.object.log_action(
'pretix.team.invite.resent', user=self.request.user, data={
'email': invite.email
}
)
messages.success(self.request, _('The invite has been resent.'))
return redirect(self.get_success_url())
elif 'remove-token' in request.POST:
try:
token = self.object.tokens.get(pk=request.POST.get('remove-token'))
except TeamAPIToken.DoesNotExist:
except (TeamAPIToken.DoesNotExist, ValueError):
messages.error(self.request, _('Invalid token selected.'))
return redirect(self.get_success_url())
else:

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