Compare commits

..

289 Commits

Author SHA1 Message Date
Raphael Michel
05e9d09e1f Don't cache files globally 2019-03-07 18:14:43 +01:00
Raphael Michel
93947cace0 Disable locking (just for a test) 2019-03-07 17:55:04 +01:00
Raphael Michel
d4eac76a8d Fix template typo 2019-02-27 09:30:43 +01:00
Raphael Michel
8889607d1c Stripe: Fix test mode recognition 2019-02-27 09:12:04 +01:00
Raphael Michel
5e9e00acec Fix tests that rely on the event wizard 2019-02-26 14:19:04 +01:00
Raphael Michel
0e89d4c0f7 Fix an AttributeError introduced in 104f84b7 2019-02-26 14:18:42 +01:00
Raphael Michel
8b3ce69425 Add clone button to event list within organizer 2019-02-26 13:10:53 +01:00
Raphael Michel
b20d1e8373 Add a second UI option to clone events 2019-02-26 13:10:53 +01:00
Raphael Michel
c278687487 Allow creating multiple events in different tabs at the same time 2019-02-26 13:10:53 +01:00
Raphael Michel
0c45e73456 Event creation: Throw user back if validation of previous step fails 2019-02-26 13:10:53 +01:00
Raphael Michel
104f84b7a8 Log change to quota when creating an item 2019-02-26 13:10:53 +01:00
Raphael Michel
ac4ecfbe69 OrderChangeManager: Fix a type error for orders without tax 2019-02-26 13:10:53 +01:00
Martin Gross
61c6cd2937 Show event date in PDF-export (Z#134372) 2019-02-23 13:47:36 +01:00
Raphael Michel
38066ca5ab Minor CSS helpers 2019-02-22 22:41:42 +01:00
Raphael Michel
373ab29701 Fix #1190 -- Voucher redemption: Default amount one if there is only one option 2019-02-22 15:41:56 +01:00
Raphael Michel
7302bba602 Add order date to CSV attendee list 2019-02-22 14:10:01 +01:00
Raphael Michel
5096121ac7 Improve QR code widget 2019-02-21 15:24:40 +01:00
Raphael Michel
ca4c21a843 Show QR code of a ticket directly from order details 2019-02-21 15:23:29 +01:00
Raphael Michel
407ecdf6c5 Fix spanish translation 2019-02-20 21:02:31 +01:00
Raphael Michel
2faeee8e9c Merge pull request #1187 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-02-20 20:54:07 +01:00
arabestia
e1bbf7139f Translated on translate.pretix.eu (Spanish)
Currently translated at 95.7% (2870 of 3000 strings)

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

powered by weblate
2019-02-20 19:53:28 +00:00
oocf
64fc38a06e Translated on translate.pretix.eu (Spanish)
Currently translated at 95.7% (2870 of 3000 strings)

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

powered by weblate
2019-02-20 19:53:27 +00:00
Alvaro Enrique Ruano
6bcf884b7a Translated on translate.pretix.eu (Spanish)
Currently translated at 95.7% (2870 of 3000 strings)

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

powered by weblate
2019-02-20 19:53:27 +00:00
Raphael Michel
d319293da8 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3000 of 3000 strings)

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

powered by weblate
2019-02-20 19:53:26 +00:00
Raphael Michel
832c58d288 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3000 of 3000 strings)

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

powered by weblate
2019-02-20 19:52:39 +00:00
Raphael Michel
c251e0e7d3 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-02-20 17:52:59 +01:00
Raphael Michel
27437e065a Update from Weblate (#1184) 2019-02-20 17:52:26 +01:00
oocf
86534aa7cc Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (68 of 68 strings)

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

powered by weblate
2019-02-20 16:51:29 +00:00
oocf
379a2140c8 Translated on translate.pretix.eu (Spanish)
Currently translated at 96.0% (2843 of 2960 strings)

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

powered by weblate
2019-02-20 16:51:29 +00:00
Raphael Michel
67059fe323 Add a simple test mode (#1181)
- [x] Provide data model and configuration toggle
- [x] Allow to delete individual test orders
- [x] Add tests
- [x] Add a prominent warning message to the backend if test mode orders exist (even though test mode is off), as this leads to wrong statistics
- [x] Decide if and how to generate invoices for test orders as invoice numbers cannot be repeated or should not have gaps.
- [x] Decide if and how we expose test orders through the API, since our difference pull mechanism relies on the fact that orders cannot be deleted.
- [x] Decide if and how we want to couple test modes of payment providers?
- [ ] pretix.eu: Ignore test orders for billing
- [ ] Adjust payment providers: Mollie, bitpay, cash, fakepayment, sepadebit

![download](https://user-images.githubusercontent.com/64280/53009081-fe420d80-343a-11e9-8361-b8511c988598.png)
2019-02-20 17:51:26 +01:00
Martin Gross
8ffc96bf31 Return pdf_data localized to the order's locale (Z#131360) 2019-02-20 16:47:29 +01:00
Raphael Michel
58b688628e Disable logging of unknown hosts 2019-02-20 16:44:00 +01:00
Raphael Michel
3f7348717b Include pending_sum in mail notifications 2019-02-20 15:12:33 +01:00
Raphael Michel
90c8e0c172 Ensure attendee name in email renderer test 2019-02-20 15:09:36 +01:00
Raphael Michel
d35ad345d7 Allow to use event meta data in email templates 2019-02-20 14:33:45 +01:00
Raphael Michel
21634369a8 Fix thumbnail scaling of portrait pictures 2019-02-20 13:50:46 +01:00
Martin Gross
a2b075c0d7 Filter sensitive keys from log-messages (#1186) 2019-02-20 13:37:44 +01:00
Martin Gross
0617abe6e3 Change test eMail address to accomodate RFC2606-sensitive mailservers (Z#134234) 2019-02-20 10:03:31 +01:00
Raphael Michel
040466353c Fix order of imports 2019-02-19 15:47:47 +01:00
Raphael Michel
46b7e9467b ibanformat filter: don't fail on empty values 2019-02-19 15:01:16 +01:00
Raphael Michel
283ff3b5e5 Merge pull request #1159 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-02-19 14:16:04 +01:00
Raphael Michel
b0bb22ea38 Translated on translate.pretix.eu (Greek)
Currently translated at 0.6% (17 of 2960 strings)

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

powered by weblate
2019-02-19 13:15:41 +00:00
Alvaro Enrique Ruano
334ee98318 Translated on translate.pretix.eu (Spanish)
Currently translated at 94.4% (2794 of 2960 strings)

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

powered by weblate
2019-02-19 12:49:40 +00:00
Alvaro Enrique Ruano
c4d342029b Translated on translate.pretix.eu (Spanish)
Currently translated at 94.4% (2794 of 2960 strings)

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

powered by weblate
2019-02-19 12:49:40 +00:00
Alvaro Enrique Ruano
bc86f9c059 Translated on translate.pretix.eu (Spanish)
Currently translated at 94.4% (2794 of 2960 strings)

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

powered by weblate
2019-02-19 12:49:40 +00:00
Alvaro Enrique Ruano
51107fe4fd Translated on translate.pretix.eu (Spanish)
Currently translated at 94.2% (2787 of 2960 strings)

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

powered by weblate
2019-02-19 12:49:40 +00:00
Martin Gross
3d65c2fd51 Migration for event.plugins non-null default 2019-02-19 13:48:32 +01:00
Alexander Schwartz
9b394b3833 Enable nl2br plugin for Markdown rendering (#1162)
The frontpage text is already markdown, and will receive its formatting via the rich_text filter.
When applying the additional linebreaksbr filter, it will add unnecessary blank lines.

I'm using the hosted pretix version. 

Test for frontpage text: 

````
Test 

* test1
* test2
````

Before (screenshot): 

---

![before](https://user-images.githubusercontent.com/3957921/52533531-5428fe00-2d35-11e9-891d-f822c3524b0e.png)

----


After (screenshot): 

----
![after](https://user-images.githubusercontent.com/3957921/52533532-568b5800-2d35-11e9-9702-4c713a110c81.png)

----
2019-02-19 12:51:33 +01:00
Martin Gross
d5747084ec Fix: Make event.plugins non-Null by default 2019-02-19 12:38:32 +01:00
Raphael Michel
777772b89e Remove spaces from locale URLs 2019-02-18 15:12:45 +01:00
Raphael Michel
c202286470 Fix #212 -- Different priorization of locale sources between backend and frontend 2019-02-18 15:12:05 +01:00
Raphael Michel
0c1738b9bb Refs #212 -- Do not set user locale if switched in frontend 2019-02-18 15:05:49 +01:00
Raphael Michel
af607083cb Add a custom field renderer for checkout 2019-02-17 21:22:47 +01:00
Raphael Michel
def7918b29 Fix wrong string interpolation in invoice generation 2019-02-17 21:22:47 +01:00
Raphael Michel
0933fc848d Order search: Fight the database optimizer to actually optimize the query 2019-02-15 11:45:06 +01:00
Raphael Michel
166f8b8a2a Fix typo in require_approval webhook 2019-02-15 08:50:36 +01:00
Raphael Michel
70fcba96a5 Add __str__ methods to more models 2019-02-14 18:50:27 +01:00
Raphael Michel
2d2d62045a Do not mark orders as paid when changed to free if they require approval 2019-02-14 18:38:33 +01:00
Raphael Michel
3988f1e2f6 Fix a typo in docs 2019-02-14 18:34:41 +01:00
Raphael Michel
d3ecb92108 Remove "refunded" from state diagram 2019-02-14 18:34:37 +01:00
Raphael Michel
b3debdfb55 Order list: Add filter for canceled with and without paid fee 2019-02-14 10:15:55 +01:00
Raphael Michel
abb770a8e7 Prevent events from being set to None through the API 2019-02-14 10:15:55 +01:00
Raphael Michel
72a2d0da35 Search in e-mail adresses during checkin 2019-02-14 10:15:55 +01:00
Raphael Michel
937cec53f7 Optimize queries for pdf_data=true 2019-02-14 10:15:55 +01:00
Raphael Michel
6e4af5da64 Perform order search on database replica 2019-02-14 10:15:02 +01:00
Raphael Michel
7ed35e06ba Allow to configure a database replica 2019-02-14 10:14:23 +01:00
Raphael Michel
55841ea660 Make sure total is calculated as a Decimal 2019-02-12 16:27:37 +01:00
Raphael Michel
78544cdb30 Implement a strong locking check to avoid race conditions during payment 2019-02-12 16:24:32 +01:00
Martin Gross
37183aced7 Disable Autocomplete for Date/Time-fields 2019-02-12 16:16:12 +01:00
Raphael Michel
a7d3cb134c Fix a token mismatch 2019-02-12 15:40:06 +01:00
Raphael Michel
da8f7f163f Check-in API: Include position data 2019-02-12 15:40:06 +01:00
Raphael Michel
89d612beed Fix bug in checkinlist view 2019-02-11 16:39:50 +01:00
Raphael Michel
f23de7e2c0 Order change: Allow to ignore quotas 2019-02-11 16:15:54 +01:00
Raphael Michel
d073007fd7 Order change: Allow to keep price when changing items 2019-02-11 16:15:13 +01:00
Raphael Michel
d9d1c83218 Optimize the check-in list view 2019-02-11 15:58:39 +01:00
Raphael Michel
ae9b8bafb8 Add missing migration 2019-02-08 15:33:26 +01:00
Raphael Michel
cbf5c2ec1d Fix ZeroDivisionError if a voucher tag is given to a voucher with max_usages=0
Fix PRETIXEU-V7
2019-02-08 13:59:05 +01:00
Raphael Michel
17392f3ef4 Store subevent data for invoice lines 2019-02-08 13:56:04 +01:00
Raphael Michel
bf36ad009f Don't request a refund if there's actually no money involved 2019-02-08 11:27:09 +01:00
Martin Gross
ca9e4823e2 Fix wrong CSV-Checkinlist link 2019-02-08 10:47:17 +01:00
Raphael Michel
d505422e0f Order overview: Make table easier to read 2019-02-07 15:28:51 +01:00
Raphael Michel
33c43ce482 Add columns lines to PDF overview export 2019-02-07 15:21:17 +01:00
Raphael Michel
f273cf4960 Fix misaligned PDF report 2019-02-07 15:21:12 +01:00
Raphael Michel
afdf09eeb4 Merge branch 'master' of github.com:pretix/pretix 2019-02-07 15:02:14 +01:00
Raphael Michel
01e5872f61 Update jsonfallback again 2019-02-07 15:01:55 +01:00
Maximilian Hils
14cc31c810 Fix payment instruction display. (#1161) 2019-02-07 14:30:26 +01:00
Raphael Michel
2972129547 Sentry: Fix a bug leading to it ignoring *everything* 2019-02-06 11:16:38 +01:00
Raphael Michel
ec4227651a Do not try to reduce voucher usage below 0 2019-02-06 10:35:54 +01:00
Raphael Michel
77950de588 Voucher bulk delete: Remove cart positions as well 2019-02-06 10:28:26 +01:00
Raphael Michel
187576eee5 Fix a ProtectedError in cart handling
FIx PRETIXEU-TR
2019-02-06 10:25:53 +01:00
Raphael Michel
0e513a0985 Require specific jsonfallback version 2019-02-06 09:59:44 +01:00
Raphael Michel
1cde728ffe Order search: Add missing field to .only() call 2019-02-06 09:52:11 +01:00
Raphael Michel
76893caffc Sentry: Ignore django.security.DisallowedHost 2019-02-05 18:15:21 +01:00
Raphael Michel
a539999c04 Sentry: Do not report retried celery tasks 2019-02-05 17:19:18 +01:00
Raphael Michel
b9c570b3d8 Sentry: Tune log levels 2019-02-05 16:35:40 +01:00
Raphael Michel
48b399424a Delete voucher even if it is contained in carts
Fix PRETIXEU-R1
2019-02-05 15:47:11 +01:00
Raphael Michel
1c73f000a9 Fix TypeError
PRETIXEU-T6
2019-02-05 15:00:58 +01:00
Raphael Michel
d0721165c1 Add distinct call back in in some cases 2019-02-05 12:10:27 +01:00
Raphael Michel
bed0a0ceeb Switch from raven to sentry_sdk 2019-02-05 11:25:58 +01:00
Raphael Michel
b53ee1dc1d Bump version to 2.5.0.dev0 2019-02-04 16:06:43 +01:00
Raphael Michel
41b56c00e5 Bump version to 2.4.0 2019-02-04 16:05:32 +01:00
Raphael Michel
cb17febf7c Add squashed migration 2019-02-04 15:48:29 +01:00
Raphael Michel
07d42a4d77 Invoice exporter: Fix missing filter on query 2019-02-04 14:47:24 +01:00
Raphael Michel
e3ebf887a4 Implement Invoice.__repr__ 2019-02-04 14:07:18 +01:00
Raphael Michel
0440187e59 Do not break on empty invoices
Sentry PRETIXEU-S8
2019-02-04 14:07:01 +01:00
Raphael Michel
dfcda0fa2c Merge pull request #1158 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-02-04 13:52:53 +01:00
Maarten van den Berg
560c0a8729 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2960 of 2960 strings)

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

powered by weblate
2019-02-04 08:45:08 +00:00
Maarten van den Berg
bc80b60b04 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2960 of 2960 strings)

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

powered by weblate
2019-02-04 08:45:08 +00:00
Maarten van den Berg
08bf3648ea Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2960 of 2960 strings)

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

powered by weblate
2019-02-04 08:45:08 +00:00
Raphael Michel
f8ee7acad6 Fix import order 2019-02-04 09:38:09 +01:00
Raphael Michel
10c86869ea Sendmail: Do not fail to show logs with status "r"
Fix sentry PRETIXEU-S3
2019-02-04 09:10:19 +01:00
Raphael Michel
9034a98df9 Remove empty translation block 2019-02-01 17:38:32 +01:00
Raphael Michel
a7142fdf55 Merge pull request #1156 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-02-01 17:38:19 +01:00
Raphael Michel
ee97c46aec Translated on translate.pretix.eu (German)
Currently translated at 99.9% (2959 of 2960 strings)

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

powered by weblate
2019-02-01 16:38:02 +00:00
Raphael Michel
7063f32f24 Translated on translate.pretix.eu (German (informal))
Currently translated at 99.9% (2959 of 2960 strings)

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

powered by weblate
2019-02-01 16:37:37 +00:00
Raphael Michel
2ec926b7c7 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-02-01 17:27:33 +01:00
Flavia Bastos
834b5a26a5 Adjust message if there's only one addon (#1147)
Relates to #1091
2019-02-01 17:26:37 +01:00
Raphael Michel
90f08d0aca Merge pull request #1146 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-02-01 17:26:00 +01:00
Alvaro Enrique Ruano
d5c2637198 Translated on translate.pretix.eu (Spanish)
Currently translated at 95.3% (2796 of 2934 strings)

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

powered by weblate
2019-02-01 16:25:27 +00:00
Raphael Michel
f517ba51bd Added translation on translate.pretix.eu (Greek) 2019-02-01 16:25:27 +00:00
Raphael Michel
d738198ec5 Added translation on translate.pretix.eu (Greek) 2019-02-01 16:25:27 +00:00
Maarten van den Berg
b1ce58d06c Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-02-01 16:25:27 +00:00
Maarten van den Berg
b26ef74128 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-02-01 16:25:27 +00:00
Maarten van den Berg
4f8c8ea917 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-02-01 16:25:27 +00:00
Maarten van den Berg
0803b049af Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-02-01 16:25:27 +00:00
Raphael Michel
97f3fbdb80 Fix legacy field name
Sentry PRETIXEU-S0
2019-02-01 17:20:48 +01:00
Raphael Michel
434b6e4729 Offer multi-sheet order report 2019-02-01 17:20:00 +01:00
Raphael Michel
f56bceb55f Remove a redundant string 2019-02-01 16:53:37 +01:00
Raphael Michel
2aa246b3d5 Allow to exclude items from ticket generation explicitly 2019-02-01 16:48:58 +01:00
Raphael Michel
f77b551aa6 Badges: Allow to disable per-product 2019-02-01 16:48:58 +01:00
Raphael Michel
c9415cba2b Allow to add a custom text above the payment choice 2019-02-01 16:48:58 +01:00
Raphael Michel
4dae224d73 Statistics: Ellipsize long product names 2019-02-01 16:48:58 +01:00
Raphael Michel
13cc57e98b Add multi-sheet export for invoices 2019-02-01 16:45:48 +01:00
Raphael Michel
6f980b82ac Sort exporters by name alphabetically 2019-01-31 18:44:12 +01:00
Raphael Michel
f32c581a9e Add MultiSheetListExporter base class 2019-01-31 18:43:42 +01:00
Thomas Schüßler
fcadfffb92 fixed a typo (#1152) 2019-01-30 14:36:20 +01:00
Raphael Michel
9e43459879 Widget: Guard against missing xhr.responseURL in Internet Explorer 2019-01-30 12:12:25 +01:00
Raphael Michel
87424c25de Logging: Automatically serialize file objects
Sentry PRETIXEU-RY
2019-01-30 10:59:00 +01:00
Raphael Michel
acdf7d62b5 Sort migrations 2019-01-30 10:17:16 +01:00
Raphael Michel
944138f7a9 Flake8 update 2019-01-30 09:31:34 +01:00
Raphael Michel
5da2eab1fb Fix deletion order 2019-01-30 09:26:01 +01:00
Raphael Michel
d680937a6c Work around flake8 issues 2019-01-30 08:59:58 +01:00
Raphael Michel
f35c2544b6 Do not attach empty files for orders without tickets 2019-01-29 17:12:38 +01:00
Raphael Michel
0285cd12f7 Optimize SQL queries in order list and order search 2019-01-29 16:45:01 +01:00
Martin Gross
03cacace57 Fix missing/redundant favicon 2019-01-28 17:57:12 +01:00
Martin Gross
6ed016e49e Define Favicons on Organizer-level 2019-01-28 17:29:39 +01:00
Raphael Michel
da8da01614 Fix #1148 -- Reduce number of cases in which we show "Reserved" 2019-01-28 10:43:52 +01:00
Raphael Michel
9a2ea6699a Fix obsolete tests 2019-01-28 09:10:18 +01:00
Raphael Michel
51a8bac9e6 Enforce type of log data 2019-01-28 09:10:18 +01:00
Raphael Michel
303ed07504 Voucher API: Never use a list in log_action(data) 2019-01-28 09:10:18 +01:00
bastardop
c7627f631f Docs: Added Debian dependency (#1149)
the libopenjp2-7-dev Packages was required during installation on raspbian
2019-01-28 08:59:49 +01:00
Raphael Michel
604c31c6e2 Bank transfer: Allow to manually accept payments of all amounts 2019-01-28 08:49:19 +01:00
Raphael Michel
c3da6731a1 Allow to delete an event with cart positions 2019-01-25 15:59:34 +01:00
Raphael Michel
6e556ab09b Fix wrong warning message 2019-01-25 13:13:45 +01:00
Raphael Michel
16622883f6 Add referer meta tag to backend 2019-01-25 11:08:56 +01:00
Raphael Michel
cce4379d3e Type-cast cancellation fee 2019-01-24 10:11:24 +01:00
Raphael Michel
5af99f4f1a Check-in: Do not ask questions that are already answered 2019-01-23 16:05:27 +01:00
Raphael Michel
9ed49888b8 Fix #1144 -- Make invoice form all-optional in backend 2019-01-23 10:27:09 +01:00
Raphael Michel
5bfb00db73 Upgrade bs4 and be compatible to latest soupsieve 2019-01-23 09:44:03 +01:00
Raphael Michel
a031d72ca9 Widget: Follow redirects 2019-01-22 18:06:56 +01:00
Raphael Michel
15a190cdf3 Widget: Remove debug output 2019-01-22 17:23:13 +01:00
Raphael Michel
d181375479 Consistent number formatting in widget 2019-01-21 10:54:30 +01:00
Raphael Michel
d8a57b0baa Conditionally show decimal places for tax rates 2019-01-21 10:53:50 +01:00
Raphael Michel
d482bc9de0 Prevent accumulation of tax rates when copying events 2019-01-21 10:43:27 +01:00
Raphael Michel
5c030796d7 Add more information to event copy choice 2019-01-21 10:37:54 +01:00
Raphael Michel
f6eb3bfb80 Remove redundant form option 2019-01-21 10:27:44 +01:00
Raphael Michel
3703fbcacf Do not allow customers to cancel checked-in orders 2019-01-21 09:09:54 +01:00
Lukas Bockstaller
cdea6eb55e Updates the dependency versions for flake 8 (#1143) 2019-01-21 08:58:26 +01:00
Maarten van den Berg
bf1e9d47d0 Fix #1111 -- Duplicate voucher warning (#1142)
Adds a new method to Voucher that selects all distinct orders containing
a position where the Voucher has been used, and changes the Voucher
detail view to use this method for the warning.
2019-01-21 08:52:31 +01:00
Raphael Michel
350df2a3cc Merge pull request #1141 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-21 08:51:21 +01:00
Maarten van den Berg
bc6915b251 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-01-21 05:00:28 +00:00
Maarten van den Berg
f9c7eeff9a Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-01-21 05:00:07 +00:00
Maarten van den Berg
247bcf0a20 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-01-20 06:00:09 +00:00
Maarten van den Berg
455c961fc7 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-01-19 21:39:08 +00:00
Maarten van den Berg
9052d4a7a9 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-01-19 21:13:37 +00:00
Raphael Michel
589401e8d2 Merge pull request #1140 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-18 17:38:24 +01:00
Raphael Michel
0c366a8473 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-01-18 16:38:00 +00:00
Raphael Michel
c9ddbd0e88 Merge pull request #1139 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-18 17:37:56 +01:00
Raphael Michel
31bf0c24f1 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-01-18 16:37:29 +00:00
Raphael Michel
c74386346b Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2934 of 2934 strings)

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

powered by weblate
2019-01-18 16:34:22 +00:00
Raphael Michel
725e1f019e Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-01-18 17:25:27 +01:00
Raphael Michel
06eddb2c6d Self-service refund form (#1135)
* Auto-refund

* Add missing template

* Notification for requested refund

* Model-level tests

* Add front-end tests

* Default to notify
2019-01-18 17:24:42 +01:00
Raphael Michel
80b5750756 New content for / index page 2019-01-18 17:24:28 +01:00
Raphael Michel
f37d265534 Refresh design for auth and error pages 2019-01-18 17:24:28 +01:00
Raphael Michel
7c4a1e5fb8 Merge pull request #1138 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-18 16:56:39 +01:00
sohalt
9a045c76ec Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2907 of 2907 strings)

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

powered by weblate
2019-01-18 03:06:42 +00:00
Lorhan Sohaky
447b36fdd3 Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 15.9% (462 of 2907 strings)

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

powered by weblate
2019-01-16 23:00:17 +00:00
Guillaume Petit
5dbd984178 Translated on translate.pretix.eu (French)
Currently translated at 80.0% (2326 of 2907 strings)

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

powered by weblate
2019-01-16 22:00:09 +00:00
Raphael Michel
95f96f8321 Fix default public name of bank transfer 2019-01-16 08:26:56 +01:00
Raphael Michel
3933032778 Merge pull request #1136 from MacLemon/tippfehler-ausbessern
Corrected language typo.
2019-01-14 22:53:09 +01:00
Pepi Zawodsky
d0b18d9f64 Corrected language typo.
Dieser Satz kein Hilfsverb.
2019-01-14 22:41:12 +01:00
Raphael Michel
71de71ed37 PDF: Fix bug with rendering name parts 2019-01-14 11:37:58 +01:00
Raphael Michel
3438d079d5 Fix a sign error 2019-01-13 11:48:52 +01:00
Raphael Michel
e7730333c2 Show refund status to customer on order page 2019-01-12 22:33:09 +01:00
Raphael Michel
e8b9f0a3ae Frontend order view: Do not recommend download for canceled orders 2019-01-12 22:19:10 +01:00
Raphael Michel
77ebd18404 Backend order view: Show canceled fees 2019-01-12 22:18:55 +01:00
Raphael Michel
2d48198c83 Ignore database-level floating point errors 2019-01-12 22:18:36 +01:00
Raphael Michel
d103b0bb84 Weblate script: pull after push 2019-01-12 17:09:18 +01:00
Raphael Michel
01411b84e4 Merge pull request #1134 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-12 17:03:02 +01:00
Raphael Michel
b7e154d8c9 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2907 of 2907 strings)

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

powered by weblate
2019-01-12 16:02:41 +00:00
Raphael Michel
f39ac96322 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2907 of 2907 strings)

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

powered by weblate
2019-01-12 16:01:53 +00:00
Raphael Michel
74db808978 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-01-12 16:55:03 +01:00
Raphael Michel
ab72b93706 Invoice: Show number of pages next to page number 2019-01-12 16:54:37 +01:00
Raphael Michel
af5aece639 Add beneficiaries to invoice addresses 2019-01-12 16:54:37 +01:00
Raphael Michel
228ab15900 Bank transfer: Allow to set custom public name 2019-01-12 16:54:37 +01:00
Raphael Michel
66164d8202 Invoice renderer: Make it easier to change fonts 2019-01-12 16:54:37 +01:00
Raphael Michel
d5ac155914 Add is_available hook for plugin configs 2019-01-12 16:54:37 +01:00
Raphael Michel
75a966529e Merge pull request #1132 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-11 15:57:47 +01:00
Raphael Michel
28a6a6185d Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2904 of 2904 strings)

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

powered by weblate
2019-01-11 14:57:27 +00:00
Raphael Michel
07cdaa9ca9 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2904 of 2904 strings)

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

powered by weblate
2019-01-11 14:57:13 +00:00
Raphael Michel
1c6935ebd9 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-01-11 15:47:30 +01:00
Raphael Michel
60c1ea8aad Allow to keep cancellation fees (#1130)
* Allow to keep cancellation fees

* Add tests and clarifications

* Add API
2019-01-11 15:42:33 +01:00
Raphael Michel
0b8798a65c Add self-crashing test 2019-01-10 18:17:29 +01:00
Raphael Michel
a8836cbeec Remove some irregularities in 8abfbba9 2019-01-10 17:41:18 +01:00
Raphael Michel
336a34b10b Merge pull request #1131 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-10 17:24:31 +01:00
Raphael Michel
c5862cc0a0 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2901 of 2901 strings)

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

powered by weblate
2019-01-10 16:23:32 +00:00
Raphael Michel
89cdcd3781 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2901 of 2901 strings)

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

powered by weblate
2019-01-10 16:21:10 +00:00
Raphael Michel
2837cac554 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-01-10 16:57:52 +01:00
Raphael Michel
3b54556739 Remove notification type for refunded event 2019-01-10 16:57:27 +01:00
Raphael Michel
4d6d6ff737 Merge pull request #1129 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-10 16:57:10 +01:00
Maarten van den Berg
ffee31e415 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-10 15:56:44 +00:00
Raphael Michel
8abfbba9d0 Refactor cancelling positions and orders in the data model (#1088)
- [x] Data model
- [x] display in order view in backend
- [x] review all usages of OrderPositions.objects
- [x] review all usages of order.positions
- [x] review all other model usages
- [x] review plugins
- [x] plugins backwards-compatible API?
- [x] decide on way forward for REST API
- [x] need to cancel fees
- [x] tests
- [ ] plugins
  - [ ] gdpr
  - [ ] reports
- [x] docs
2019-01-10 16:52:34 +01:00
Raphael Michel
588955901c Pin oauthlib version 2019-01-09 14:19:02 +01:00
Raphael Michel
4b7bf2f27f Merge pull request #1118 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2019-01-09 12:33:18 +01:00
Raphael Michel
664957e886 Add now_date 2019-01-09 12:29:37 +01:00
Raphael Michel
f15a6d39c3 Add now_* variables to PDFs 2019-01-09 12:17:31 +01:00
Maarten van den Berg
3fd80a9a46 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (68 of 68 strings)

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

powered by weblate
2019-01-08 21:00:37 +00:00
Maarten van den Berg
2fd2716303 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-08 21:00:08 +00:00
Maarten van den Berg
37315fc380 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-08 13:00:29 +00:00
Maarten van den Berg
f96fc0744e Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (68 of 68 strings)

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

powered by weblate
2019-01-08 12:30:26 +00:00
Maarten van den Berg
5bb7883020 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-08 12:25:27 +00:00
Maarten van den Berg
3f95434922 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Maarten van den Berg
08da5a8b91 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (68 of 68 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Maarten van den Berg
97dc4421ea Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Maarten van den Berg
26ca2ff006 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (68 of 68 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Maarten van den Berg
980c359f57 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 2.9% (2 of 68 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Maarten van den Berg
ff1198dec6 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Raphael Michel
7275de94af Added translation on translate.pretix.eu (Dutch (informal)) 2019-01-07 09:21:52 +00:00
Raphael Michel
ed46f41f8c Added translation on translate.pretix.eu (Dutch (informal)) 2019-01-07 09:21:52 +00:00
Alexey Zh
1078e38890 Translated on translate.pretix.eu (Russian)
Currently translated at 16.2% (11 of 68 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
amefad
2e9bbff308 Translated on translate.pretix.eu (Italian)
Currently translated at 33.8% (23 of 68 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Maarten van den Berg
13a48701fa Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (68 of 68 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Alexey Zh
ddc9c850c0 Translated on translate.pretix.eu (Russian)
Currently translated at 0.4% (13 of 2890 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
amefad
fa0dae6ed6 Translated on translate.pretix.eu (Italian)
Currently translated at 2.7% (79 of 2890 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Maarten van den Berg
da6176a51e Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Maarten van den Berg
4ef6659551 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (68 of 68 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Alexey Zh
82624a1dc0 Added translation on translate.pretix.eu (Russian) 2019-01-07 09:21:52 +00:00
Alexey Zh
b50add260a Added translation on translate.pretix.eu (Russian) 2019-01-07 09:21:52 +00:00
Bruno Damasceno Martins
f72f97d366 Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 10.4% (300 of 2890 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Alexander Schwartz
ad46e9e541 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2019-01-07 09:21:52 +00:00
Raphael Michel
343dbc00be Bank transfer: Add a note on how to proceed 2019-01-07 10:21:35 +01:00
Raphael Michel
3cb94f702d Revert accidental commit part 2019-01-04 10:24:06 +01:00
Raphael Michel
ddeae224fb Log SMTP failures and retry after some error codes 2019-01-04 09:54:43 +01:00
Raphael Michel
3c57895101 Don't mark orders as pending unnecessarily 2019-01-03 09:50:56 +01:00
Raphael Michel
687c85eb58 Stripe: Full source state handling 2019-01-03 09:43:07 +01:00
Raphael Michel
90ffdbdfa3 Stripe: Allow failed payments to succeed 2019-01-03 09:37:31 +01:00
Raphael Michel
654be0db34 Stripe: Prevent race condition between ReturnView and webhook 2019-01-03 09:34:15 +01:00
Raphael Michel
82e3359b40 Allow to filter subevents by date 2019-01-02 16:59:03 +01:00
Raphael Michel
01a6861453 Always query emails case-insensitively 2019-01-02 15:12:48 +01:00
Raphael Michel
7f6cdd6241 Fix ProtectedError when deleting expired card positions 2019-01-02 15:05:30 +01:00
Raphael Michel
aad1fda31f Register nl_Informal translation 2019-01-02 10:12:17 +01:00
Raphael Michel
ad462921f0 Pin bs4 version due to regression 2019-01-02 09:58:50 +01:00
Raphael Michel
dc433f6420 Reverse on global URL config in build_absolute_uri 2019-01-02 09:24:47 +01:00
Raphael Michel
2d8b3d1c79 PayPal: Fix backwards compatibility 2019-01-02 09:20:35 +01:00
Raphael Michel
eb85fa956e Badges: Add per-position downloads 2018-12-19 12:31:44 +01:00
Raphael Michel
215514fca7 Add ticket downloads to the backend 2018-12-19 12:31:24 +01:00
Raphael Michel
3fe2dfe810 Add signal order_position_buttons 2018-12-19 12:29:52 +01:00
Raphael Michel
041d05eb66 Support product pictures for add-on products 2018-12-19 09:37:30 +01:00
Raphael Michel
d05530ddfc Explicit ordering of check-in lists 2018-12-19 09:20:44 +01:00
Raphael Michel
734e77d1a3 API: Allow to redeem ticket by secret 2018-12-18 12:23:07 +01:00
Raphael Michel
633061e203 Avoid paid orders without payment_date 2018-12-18 10:07:17 +01:00
Raphael Michel
e11ee4a427 Do not allow to delete vouchers assigned to canceled orders 2018-12-18 10:07:17 +01:00
Alvaro Enrique Ruano
1edcd47703 Support for daterange in spanish (#1125) 2018-12-17 22:32:15 +01:00
Raphael Michel
cf4b2544f2 Never create implicit payments for orders that require approval 2018-12-14 10:42:08 +01:00
Raphael Michel
04c3cffd43 Fix severe translation mistake 2018-12-12 16:42:47 +01:00
Raphael Michel
483d41c7a6 Event plugin list: Use a more useful sorting of the list 2018-12-12 16:42:47 +01:00
Martin Gross
b0c4c88d01 Fix #1119 - Proper indent and pluralisation (#1120) 2018-12-12 08:59:54 +01:00
Martin Gross
518298f71c Add media-src CSP to middleware (#1121) 2018-12-12 08:59:22 +01:00
Raphael Michel
62c2e7765b Fix wrong variable 2018-12-11 17:00:05 +01:00
Raphael Michel
2bb2a40509 Add new signal checkout_all_optional 2018-12-11 16:44:15 +01:00
Raphael Michel
49828186b0 Signals: Pretictable call order, not return order 2018-12-11 16:43:07 +01:00
Raphael Michel
c07a6cb4aa Small query optimization 2018-12-11 16:15:54 +01:00
Tobias Kunze
67ad9a0dcb Provide send_robust on EventSignals (#1116) 2018-12-11 16:15:22 +01:00
Raphael Michel
d267dfc682 Fix #785 -- Show availability in (sub)event list (#1112) 2018-12-11 13:59:49 +01:00
Raphael Michel
eed220f14a pytest-xdist is required by now 2018-12-11 12:54:30 +01:00
Raphael Michel
85289fe0d1 Fix error when marking an expired order as paid 2018-12-07 11:04:41 +01:00
Raphael Michel
6293ad34d4 Add a headline to payment provider settings 2018-12-06 10:56:40 +01:00
Raphael Michel
0dc4f61cf0 Fix a docs spelling error 2018-12-06 10:42:50 +01:00
Raphael Michel
6849e682d7 Bump verstion to 2.4.0.dev0 2018-12-06 10:17:39 +01:00
269 changed files with 90912 additions and 27436 deletions

View File

@@ -38,7 +38,7 @@ if [ "$1" == "translation-spelling" ]; then
potypo
fi
if [ "$1" == "tests" ]; then
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt pytest-xdist
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
cd src
python manage.py check
make all compress

View File

@@ -125,6 +125,23 @@ Example::
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
turns on some optimizations/special case handlers. Default: ``False``
Database replica settings
-------------------------
If you use a replicated database setup, pretix expects that the default database connection always points to the primary database node.
Routing read queries to a replica on database layer is **strongly** discouraged since this can lead to inaccurate such as more tickets
being sold than are actually available.
However, pretix can still make use of a database replica to keep some expensive queries with that can tolerate some latency from your
primary database, such as backend search queries. The ``replica`` configuration section can have the same settings as the ``database``
section (except for the ``backend`` setting) and will default back to the ``database`` settings for all values that are not given. This
way, you just need to specify the settings that are different for the replica.
Example::
[replica]
host=192.168.0.2
URLs
----

View File

@@ -64,7 +64,7 @@ To build and run pretix, you will need the following debian packages::
# apt-get install git build-essential python-dev python-virtualenv python3 python3-pip \
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
gettext libpq-dev libmysqlclient-dev libjpeg-dev
gettext libpq-dev libmysqlclient-dev libjpeg-dev libopenjp2-7-dev
Config file
-----------

View File

@@ -445,6 +445,8 @@ Order position endpoints
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.
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
**Example request**:
.. sourcecode:: http
@@ -516,6 +518,8 @@ Order position endpoints
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
accepts a number of optional requests in the body.
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
you do not implement question handling in your user interface, you **must**
set this to ``false``. In that case, questions will just be ignored. Defaults
@@ -561,7 +565,10 @@ Order position endpoints
Content-Type: application/json
{
"status": "ok"
"status": "ok",
"position": {
}
}
**Example response with required questions**:
@@ -572,7 +579,10 @@ Order position endpoints
Content-Type: text/json
{
"status": "incomplete"
"status": "incomplete",
"position": {
},
"questions": [
{
"id": 1,
@@ -617,6 +627,9 @@ Order position endpoints
{
"status": "error",
"reason": "unpaid",
"position": {
}
}
Possible error reasons:

View File

@@ -15,6 +15,7 @@ name multi-lingual string The event's ful
slug string A short form of the name, used e.g. in URLs.
live boolean If ``true``, the event ticket shop is publicly
available.
testmode boolean If ``true``, the ticket shop is in test mode.
currency string The currency this event is handled in.
date_from datetime The event's start date
date_to datetime The event's end date (or ``null``)
@@ -45,6 +46,10 @@ plugins list A list of packa
Filters have been added to the list of events.
.. versionchanged:: 2.5
The ``testmode`` attribute has been added.
Endpoints
---------
@@ -79,6 +84,7 @@ Endpoints
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"testmode": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
@@ -137,6 +143,7 @@ Endpoints
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"testmode": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
@@ -183,6 +190,7 @@ Endpoints
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"testmode": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
@@ -211,6 +219,7 @@ Endpoints
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"testmode": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
@@ -259,6 +268,7 @@ Endpoints
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"testmode": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
@@ -287,6 +297,7 @@ Endpoints
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"testmode": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
@@ -347,6 +358,7 @@ Endpoints
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"testmode": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,

View File

@@ -64,6 +64,12 @@ original_price money (string) An original pri
require_approval boolean If ``True``, orders with this product will need to be
approved by the event organizer before they can be
paid.
generate_tickets boolean If ``False``, tickets are never generated for this
product, regardless of other settings. If ``True``,
tickets are generated even if this is a
non-admission or add-on product, regardless of event
settings. If this is ``null``, regular ticketing
rules apply.
has_variations boolean Shows whether or not this item has variations.
variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation,
@@ -111,6 +117,10 @@ addons list of objects Definition of a
The ``sales_channels`` attribute has been added.
.. versionchanged:: 2.4
The ``generate_tickets`` attribute has been added.
Notes
-----
Please note that an item either always has variations or never has. Once created with variations the item can never
@@ -174,6 +184,7 @@ Endpoints
"max_per_order": null,
"checkin_attention": false,
"has_variations": false,
"generate_tickets": null,
"require_approval": false,
"variations": [
{
@@ -256,6 +267,7 @@ Endpoints
"require_voucher": false,
"hide_without_voucher": false,
"allow_cancel": true,
"generate_tickets": null,
"min_per_order": null,
"max_per_order": null,
"checkin_attention": false,
@@ -323,6 +335,7 @@ Endpoints
"require_voucher": false,
"hide_without_voucher": false,
"allow_cancel": true,
"generate_tickets": null,
"min_per_order": null,
"max_per_order": null,
"checkin_attention": false,
@@ -379,6 +392,7 @@ Endpoints
"allow_cancel": true,
"min_per_order": null,
"max_per_order": null,
"generate_tickets": null,
"checkin_attention": false,
"has_variations": true,
"require_approval": false,
@@ -462,6 +476,7 @@ Endpoints
"available_until": null,
"require_voucher": false,
"hide_without_voucher": false,
"generate_tickets": null,
"allow_cancel": true,
"min_per_order": null,
"max_per_order": null,

View File

@@ -26,7 +26,8 @@ status string Order status, o
* ``p`` paid
* ``e`` expired
* ``c`` canceled
* ``r`` refunded
testmode boolean If ``True``, this order was created when the event was in
test mode. Only orders in test mode can be deleted.
secret string The secret contained in the link sent to the customer
email string The customer email address
locale string The locale used for communication with this customer
@@ -58,9 +59,9 @@ invoice_address object Invoice address
└ vat_id_validated string ``True``, if the VAT ID has been validated against the
EU VAT service and validation was successful. This only
happens in rare cases.
positions list of objects List of order positions (see below)
fees list of objects List of fees included in the order total (i.e.
payment fees)
positions list of objects List of non-canceled order positions (see below)
fees list of objects List of non-canceled fees included in the order total
(i.e. payment fees)
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``other``)
├ value money (string) Fee amount
@@ -127,6 +128,15 @@ last_modified datetime Last modificati
The ``sales_channel`` attribute has been added.
.. versionchanged:: 2.4:
``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and
``…/mark_refunded/`` has been deprecated.
.. versionchanged:: 2.5:
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
.. _order-position-resource:
Order position resource
@@ -268,6 +278,7 @@ List of all orders
{
"code": "ABC12",
"status": "p",
"testmode": false,
"secret": "k24fiuwvu8kxz3y1",
"email": "tester@example.org",
"locale": "en",
@@ -366,11 +377,14 @@ List of all orders
``status``. Default: ``datetime``
:query string code: Only return orders that match the given order code
:query string status: Only return orders in the given order status (see above)
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
``require_approval`` will be returned.
:query string email: Only return orders created with the given email address
:query string locale: Only return orders with the given customer locale
:query datetime modified_since: Only return orders that have changed since the given date
:query datetime modified_since: Only return orders that have changed since the given date. Be careful: We only
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
you will not notice it using this method.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
@@ -405,6 +419,7 @@ Fetching individual orders
{
"code": "ABC12",
"status": "p",
"testmode": false,
"secret": "k24fiuwvu8kxz3y1",
"email": "tester@example.org",
"locale": "en",
@@ -548,6 +563,37 @@ Order ticket download
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
Deleting orders
---------------
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
Deletes an order. Works only if the order has ``testmode`` set to ``true``.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/json
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource **or** the order may not be deleted.
:statuscode 404: The requested order does not exist.
Creating orders
---------------
@@ -602,6 +648,7 @@ Creating orders
or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will
**not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and
then call the ``mark_paid`` API method.
* ``testmode`` (optional) Defaults to ``false``
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas that become free by this operation will be credited to your order
creation.
@@ -776,7 +823,10 @@ Order state operations
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_canceled/
Marks a pending order as canceled.
Cancels an order. For a pending order, this will set the order to status ``c``. For a paid order, this will set
the order to status ``c`` if no ``cancellation_fee`` is passed. If you do pass a ``cancellation_fee``, the order
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
fee as the only component of the order.
**Example request**:
@@ -788,7 +838,8 @@ Order state operations
Content-Type: text/json
{
"send_email": true
"send_email": true,
"cancellation_fee": null
}
**Example response**:
@@ -849,44 +900,6 @@ Order state operations
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_refunded/
Marks a paid order as refunded.
.. warning:: In the current implementation, this will **bypass** the payment provider, i.e. the money will **not** be
transferred back to the user automatically, the order will only be *marked* as refunded within pretix.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_expired/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"code": "ABC12",
"status": "r",
...
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param code: The ``code`` field of the order to modify
:statuscode 200: no error
:statuscode 400: The order cannot be marked as expired since the current order status does not allow it.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
Marks a unpaid order as expired.
@@ -1066,6 +1079,8 @@ List of all order positions
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
``pseudonymization_id``.
.. note:: Individually canceled order positions are currently not visible via the API at all.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Returns a list of all order positions within a given event.
@@ -1501,7 +1516,7 @@ Order payment endpoints
{
"amount": "23.00",
"mark_refunded": false
"mark_canceled": false
}
@@ -1648,7 +1663,7 @@ Order refund endpoints
"payment": 1,
"execution_date": null,
"provider": "manual",
"mark_refunded": false
"mark_canceled": false
}
**Example response**:
@@ -1718,7 +1733,7 @@ Order refund endpoints
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``.
Acts on an external refund, either marks the order as canceled or pending. Only allowed in state ``external``.
**Example request**:
@@ -1729,7 +1744,7 @@ Order refund endpoints
Accept: application/json, text/javascript
Content-Type: application/json
{"mark_refunded": false}
{"mark_canceled": false}
**Example response**:

View File

@@ -38,7 +38,6 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.order.contact.changed``
* ``pretix.event.order.changed.*``
* ``pretix.event.order.refund.created.externally``
* ``pretix.event.order.refunded``
* ``pretix.event.order.approved``
* ``pretix.event.order.denied``
* ``pretix.event.checkin``

View File

@@ -26,7 +26,7 @@ Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content
: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
.. automodule:: pretix.presale.signals
@@ -49,7 +49,7 @@ Backend
.. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, event_settings_widget, oauth_application_registered
order_info, event_settings_widget, oauth_application_registered, order_position_buttons
.. automodule:: pretix.base.signals

View File

@@ -23,7 +23,7 @@ that we'll provide in this plugin::
@receiver(register_invoice_renderers, dispatch_uid="output_custom")
def register_infoice_renderers(sender, **kwargs):
def register_invoice_renderers(sender, **kwargs):
from .invoice import MyInvoiceRenderer
return MyInvoiceRenderer

View File

@@ -114,6 +114,8 @@ The provider class
.. autoattribute:: is_meta
.. autoattribute:: test_mode_message
Additional views
----------------

View File

@@ -79,6 +79,9 @@ human-readable error messages. We recommend using the ``django.utils.functional.
decorator, as it might get called a lot. You can also implement ``compatibility_warnings``,
those will be displayed but not block the plugin execution.
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
is available for a specific event. If not, it will not be shown in the plugin list of that event.
Plugin registration
-------------------

View File

@@ -82,6 +82,12 @@ Orders
^^^^^^
If a customer completes the checkout process, an **Order** will be created containing all the entered information.
An order can be in one of currently five states that are listed in the diagram below:
An order can be in one of currently four states that are listed in the diagram below:
.. image:: /images/order_states.png
There are additional "fake" states that are displayed like states but not represented as states in the system:
* An order is considered **canceled (with paid fee)** if it is in **paid** status but does not include any non-cancelled positions.
* An order is considered **requiring approval** if it is in **pending** status with the ``require_approval`` attribute set to ``True``.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -4,7 +4,6 @@ Pending: Order is expecting payment\nOrder reduces quotas
Expired: Payment period is over\nOrder does not affect quotas
Paid: Order was successful\nOrder reduces quotas
Canceled: Order has been canceled\nOrder does not affect quotas
Refunded: Order has been refunded\nOrder does not affect quotas
[*] --> Pending: customer\nplaces order
Pending --> Paid: successful payment
@@ -12,8 +11,9 @@ Pending --> Expired: automatically\nor manually\non admin action
Expired --> Paid: if payment is received\nonly if quota left
Expired --> Canceled
Expired --> Pending: manually\non admin action
Paid --> Refunded: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
Paid --> Canceled: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
Pending --> Canceled: on admin or\ncustomer action
Paid -> Pending: manually on admin action
[*] --> Paid: customer\nplaces free order
@enduml

View File

@@ -6,6 +6,7 @@ api
auditability
auth
autobuild
availabilities
backend
backends
banktransfer
@@ -110,6 +111,7 @@ submodule
subpath
Symfony
systemd
testmode
testutils
timestamp
tuples

View File

@@ -4,22 +4,10 @@ FAQ and Troubleshooting
How can I test my shop before taking it live?
---------------------------------------------
There are multiple ways to do this.
First, you could just create some orders in your real shop and cancel/refund them later. If you don't want to process
real payments for the tests, you can either use a "manual" payment method like bank transfer and just mark the orders
as paid with the button in the backend, or if you want to use e.g. Stripe, you can configure pretix to use your keys
for the Stripe test system and use their test credit cars. Read our :ref:`Stripe documentation <stripe>` for more
information.
Second, you could create a separate event, just for testing. In the last step of the :ref:`event creation process <event_create>`,
you can specify that you want to copy all settings from your real event, so you don't have to do all of it twice.
We are planning to add a dedicated test mode in a later version of pretix.
If you are using the hosted service at pretix.eu and want to get rid of the test orders completely, contact us at
support@pretix.eu and we can remove them for you. Please note that we only are able to do that *before* you have
received any real orders (i.e. taken the shop public). We won't charge any fees for test orders or test events.
On your event dashboard, click on the first tile that shows your shop status. On the lower part of this page, you can
place your event into "test mode". In "test mode", everything behaves the same, but orders created during test mode can
later be fully deleted. Be sure to actually delete them when or after you turn off test mode, since test mode orders
still count toward your quotas and are included in your reports.
How do I delete an event?
-------------------------

View File

@@ -34,4 +34,5 @@ git push
# Unlock Weblate
for c in $COMPONENTS; do
wlc unlock $c;
wlc pull $c;
done

View File

@@ -1 +1 @@
__version__ = "2.3.0"
__version__ = "2.5.0.dev0"

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.functional import cached_property
@@ -47,7 +48,7 @@ class EventSerializer(I18nAwareModelSerializer):
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'currency', 'date_from',
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
@@ -95,7 +96,7 @@ class EventSerializer(I18nAwareModelSerializer):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module for p in get_all_plugins()
p.module for p in get_all_plugins(self.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
@@ -108,7 +109,7 @@ class EventSerializer(I18nAwareModelSerializer):
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
event = super().create(validated_data)
# Meta data
@@ -122,6 +123,7 @@ class EventSerializer(I18nAwareModelSerializer):
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
event.save(update_fields=['plugins'])
return event

View File

@@ -79,7 +79,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
'variations', 'addons', 'original_price', 'require_approval')
'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets')
read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self):

View File

@@ -12,6 +12,7 @@ from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
Question, QuestionAnswer,
@@ -114,9 +115,7 @@ class PositionDownloadsField(serializers.Field):
if instance.order.status != Order.STATUS_PAID:
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
return []
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
return []
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
if not instance.generate_ticket:
return []
request = self.context['request']
@@ -142,20 +141,21 @@ class PdfDataSerializer(serializers.Field):
res = {}
ev = instance.subevent or instance.order.event
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
# we serialize a list.
with language(instance.order.locale):
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
# we serialize a list.
if 'vars' not in self.context:
self.context['vars'] = get_variables(self.context['request'].event)
if 'vars' not in self.context:
self.context['vars'] = get_variables(self.context['request'].event)
for k, f in self.context['vars'].items():
res[k] = f['evaluate'](instance, instance.order, ev)
for k, f in self.context['vars'].items():
res[k] = f['evaluate'](instance, instance.order, ev)
if not hasattr(ev, '_cached_meta_data'):
ev._cached_meta_data = ev.meta_data
if not hasattr(ev, '_cached_meta_data'):
ev._cached_meta_data = ev.meta_data
for k, v in ev._cached_meta_data.items():
res['meta:' + k] = v
for k, v in ev._cached_meta_data.items():
res['meta:' + k] = v
return res
@@ -231,7 +231,7 @@ class OrderSerializer(I18nAwareModelSerializer):
class Meta:
model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
fields = ('code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel')
@@ -324,7 +324,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
'secret', 'addon_to', 'subevent', 'answers')
def validate_secret(self, secret):
if secret and OrderPosition.objects.filter(order__event=self.context['event'], secret=secret).exists():
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
raise ValidationError(
'You cannot assign a position secret that already exists.'
)
@@ -413,7 +413,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
class Meta:
model = Order
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
def validate_payment_provider(self, pp):
@@ -557,7 +557,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.status = Order.STATUS_PAID
order.save()
order.payments.create(
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
payment_date=now()
)
elif payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')

View File

@@ -1,5 +1,5 @@
from django.core.exceptions import ValidationError
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
from django.db.models.functions import Coalesce
from django.http import Http404
from django.shortcuts import get_object_or_404
@@ -16,7 +16,9 @@ from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
from pretix.base.models import (
Checkin, CheckinList, Event, Order, OrderPosition,
)
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, perform_checkin,
)
@@ -201,12 +203,39 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
subevent=self.checkinlist.subevent
).annotate(
last_checked_in=Subquery(cqs)
).prefetch_related(
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
)
if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related(
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
),
'checkins', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
Prefetch(
'event',
Event.objects.select_related('organizer')
),
Prefetch(
'positions',
OrderPosition.objects.prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
)
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to'
)
).select_related('item', 'variation', 'order', 'addon_to')
else:
qs = qs.prefetch_related(
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
if not self.checkinlist.all_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
@@ -251,6 +280,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
except RequiredQuestionsError as e:
return Response({
'status': 'incomplete',
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
'questions': [
QuestionSerializer(q).data for q in e.questions
]
@@ -258,9 +289,21 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
except CheckInError as e:
return Response({
'status': 'error',
'reason': e.code
'reason': e.code,
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': OrderPositionSerializer(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
}, status=201)
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
if self.kwargs['pk'].isnumeric():
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:
obj = get_object_or_404(queryset, secret=self.kwargs['pk'])
return obj

View File

@@ -1,4 +1,5 @@
import datetime
from decimal import Decimal
import django_filters
import pytz
@@ -15,7 +16,7 @@ from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
)
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
@@ -25,8 +26,8 @@ from pretix.api.serializers.order import (
OrderRefundSerializer, OrderSerializer,
)
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Invoice, Order, OrderPayment,
OrderPosition, OrderRefund, Quota, TeamAPIToken,
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
)
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
@@ -50,10 +51,10 @@ class OrderFilter(FilterSet):
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'require_approval']
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -82,6 +83,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
)
)
@@ -186,6 +188,12 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
@detail_route(methods=['POST'])
def mark_canceled(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
cancellation_fee = request.data.get('cancellation_fee', None)
if cancellation_fee:
try:
cancellation_fee = float(Decimal(cancellation_fee))
except:
cancellation_fee = None
order = self.get_object()
if not order.cancel_allowed():
@@ -194,14 +202,21 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
cancel_order(
order,
user=request.user if request.user.is_authenticated else None,
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
device=request.auth if isinstance(request.auth, Device) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail
)
try:
cancel_order(
order,
user=request.user if request.user.is_authenticated else None,
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
device=request.auth if isinstance(request.auth, Device) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail,
cancellation_fee=cancellation_fee
)
except OrderError as e:
return Response(
{'detail': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
@@ -363,6 +378,13 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
def perform_create(self, serializer):
serializer.save()
def perform_destroy(self, instance):
if not instance.testmode:
raise PermissionDenied('Only test mode orders can be deleted.')
with transaction.atomic():
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
@@ -377,6 +399,7 @@ class OrderPositionFilter(FilterSet):
| Q(addon_to__attendee_name_cached__icontains=value)
| Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value)
)
def has_checkin_qs(self, queryset, name, value):
@@ -421,11 +444,33 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
}
def get_queryset(self):
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question'
).select_related(
'item', 'order', 'order__event', 'order__event__organizer'
)
qs = OrderPosition.objects.filter(order__event=self.request.event)
if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
Prefetch(
'event',
Event.objects.select_related('organizer')
),
Prefetch(
'positions',
OrderPosition.objects.prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
)
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to'
)
else:
qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question'
).select_related(
'item', 'order', 'order__event', 'order__event__organizer'
)
return qs
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
@@ -442,10 +487,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
if pos.order.status != Order.STATUS_PAID:
raise PermissionDenied("Downloads are not available for unpaid orders.")
if pos.addon_to_id and not request.event.settings.ticket_download_addons:
raise PermissionDenied("Downloads are not enabled for add-on products.")
if not pos.item.admission and not request.event.settings.ticket_download_nonadm:
raise PermissionDenied("Downloads are not enabled for non-admission products.")
if not pos.generate_ticket:
raise PermissionDenied("Downloads are not enabled for this product.")
ct = CachedTicket.objects.filter(
order_position=pos, provider=provider.identifier, file__isnull=False
@@ -515,7 +558,10 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
request.data.get('amount', str(payment.amount))
)
mark_refunded = request.data.get('mark_refunded', False)
if 'mark_refunded' in request.data:
mark_refunded = request.data.get('mark_refunded', False)
else:
mark_refunded = request.data.get('mark_canceled', False)
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
@@ -624,10 +670,14 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
if request.data.get('mark_refunded', False):
if 'mark_refunded' in request.data:
mark_refunded = request.data.get('mark_refunded', False)
else:
mark_refunded = request.data.get('mark_canceled', False)
if mark_refunded:
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth)
else:
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
refund.order.status = Order.STATUS_PENDING
refund.order.set_expires(
now(),
@@ -653,7 +703,10 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
return ctx
def create(self, request, *args, **kwargs):
mark_refunded = request.data.pop('mark_refunded', False)
if 'mark_refunded' in request.data:
mark_refunded = request.data.pop('mark_refunded', False)
else:
mark_refunded = request.data.pop('mark_canceled', False)
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic():
@@ -770,7 +823,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
raise PermissionDenied('The invoice file is no longer stored on the server.')
else:
c = generate_cancellation(inv)
if inv.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
if inv.order.status != Order.STATUS_CANCELED:
inv = generate_invoice(inv.order)
else:
inv = c

View File

@@ -124,12 +124,12 @@ class VoucherViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
with transaction.atomic():
serializer.save(event=self.request.event)
for i in serializer.instance:
i.log_action(
for i, v in enumerate(serializer.instance):
v.log_action(
'pretix.voucher.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
data=self.request.data[i]
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@@ -113,7 +113,7 @@ def register_default_webhook_events(sender, **kwargs):
_('New order placed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.placed.required_approval',
'pretix.event.order.placed.require_approval',
_('New order requires approval'),
),
ParametrizedOrderWebhookEvent(
@@ -144,10 +144,6 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.refund.created.externally',
_('External refund of payment'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refunded',
_('Order refunded'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.approved',
_('Order approved'),

View File

@@ -27,7 +27,7 @@ class CustomSMTPBackend(EmailBackend):
if code != 250:
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
(code, resp) = self.connection.rcpt('test@example.com')
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
if (code != 250) and (code != 251):
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)

View File

@@ -5,7 +5,7 @@ from typing import Tuple
from defusedcsv import csv
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext, ugettext_lazy as _
from openpyxl import Workbook
from openpyxl.cell.cell import KNOWN_TYPES
@@ -143,3 +143,73 @@ class ListExporter(BaseExporter):
return self._render_csv(form_data, dialect='excel')
elif form_data.get('_format') == 'semicolon':
return self._render_csv(form_data, dialect='excel', delimiter=';')
class MultiSheetListExporter(ListExporter):
@property
def sheets(self):
raise NotImplementedError()
@property
def export_form_fields(self) -> dict:
choices = [
('xlsx', _('Combined Excel (.xlsx)')),
]
for s, l in self.sheets:
choices += [
(s + ':default', str(l) + ' ' + ugettext('CSV (with commas)')),
(s + ':excel', str(l) + ' ' + ugettext('CSV (Excel-style)')),
(s + ':semicolon', str(l) + ' ' + ugettext('CSV (with semicolons)')),
]
ff = OrderedDict(
[
('_format',
forms.ChoiceField(
label=_('Export format'),
choices=choices,
)),
]
)
ff.update(self.additional_form_fields)
return ff
def iterate_list(self, form_data):
pass
def iterate_sheet(self, form_data, sheet):
raise NotImplementedError() # noqa
def _render_sheet_csv(self, form_data, sheet, **kwargs):
output = io.StringIO()
writer = csv.writer(output, **kwargs)
for line in self.iterate_sheet(form_data, sheet):
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data):
wb = Workbook()
ws = wb.get_active_sheet()
wb.remove(ws)
for s, l in self.sheets:
ws = wb.create_sheet(str(l))
for i, line in enumerate(self.iterate_sheet(form_data, sheet=s)):
for j, val in enumerate(line):
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
wb.save(f.name)
f.seek(0)
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
if form_data.get('_format') == 'xlsx':
return self._render_xlsx(form_data)
elif ':' in form_data.get('_format'):
sheet, f = form_data.get('_format').split(':')
if f == 'default':
return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
elif f == 'excel':
return self._render_sheet_csv(form_data, sheet, dialect='excel')
elif f == 'semicolon':
return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';')

View File

@@ -10,7 +10,7 @@ from ..signals import register_data_exporters
class JSONExporter(BaseExporter):
identifier = 'json'
verbose_name = 'JSON'
verbose_name = 'Order data (JSON)'
def render(self, form_data):
jo = {

View File

@@ -3,22 +3,32 @@ from decimal import Decimal
import pytz
from django import forms
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
from django.dispatch import receiver
from django.utils.formats import localize
from django.utils.formats import date_format, localize
from django.utils.translation import ugettext as _, ugettext_lazy
from pretix.base.models import InvoiceAddress, Order, OrderPosition
from pretix.base.models import (
InvoiceAddress, InvoiceLine, Order, OrderPosition,
)
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.settings import PERSON_NAME_SCHEMES
from ..exporter import ListExporter
from ..exporter import ListExporter, MultiSheetListExporter
from ..signals import register_data_exporters
class OrderListExporter(ListExporter):
class OrderListExporter(MultiSheetListExporter):
identifier = 'orderlist'
verbose_name = ugettext_lazy('List of orders')
verbose_name = ugettext_lazy('Order data')
@property
def sheets(self):
return (
('orders', _('Orders')),
('positions', _('Order positions')),
('fees', _('Order fees')),
)
@property
def additional_form_fields(self):
@@ -49,7 +59,15 @@ class OrderListExporter(ListExporter):
tax_rates = sorted(tax_rates)
return tax_rates
def iterate_list(self, form_data: dict):
def iterate_sheet(self, form_data, sheet):
if sheet == 'orders':
return self.iterate_orders(form_data)
elif sheet == 'positions':
return self.iterate_positions(form_data)
elif sheet == 'fees':
return self.iterate_fees(form_data)
def iterate_orders(self, form_data: dict):
tz = pytz.timezone(self.event.settings.timezone)
p_date = OrderPayment.objects.filter(
@@ -160,13 +178,188 @@ class OrderListExporter(ListExporter):
row.append(', '.join([i.number for i in order.invoices.all()]))
yield row
def iterate_fees(self, form_data: dict):
tz = pytz.timezone(self.event.settings.timezone)
qs = OrderFee.objects.filter(
order__event=self.event,
).select_related('order', 'order__invoice_address', 'tax_rule')
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
headers = [
_('Order code'),
_('Status'),
_('Email'),
_('Order date'),
_('Fee type'),
_('Description'),
_('Price'),
_('Tax rate'),
_('Tax rule'),
_('Tax value'),
_('Company'),
_('Invoice address name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
if len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label))
headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
]
yield headers
for op in qs.order_by('order__datetime'):
order = op.order
row = [
order.code,
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
op.get_fee_type_display(),
op.description,
op.value,
op.tax_rate,
str(op.tax_rule) if op.tax_rule else '',
op.tax_value,
]
try:
row += [
order.invoice_address.company,
order.invoice_address.name,
]
if len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
order.invoice_address.name_parts.get(k, '')
)
row += [
order.invoice_address.street,
order.invoice_address.zipcode,
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
yield row
def iterate_positions(self, form_data: dict):
tz = pytz.timezone(self.event.settings.timezone)
qs = OrderPosition.objects.filter(
order__event=self.event,
).select_related(
'order', 'order__invoice_address', 'item', 'variation',
'voucher', 'tax_rule'
).prefetch_related(
'answers', 'answers__question'
)
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
headers = [
_('Order code'),
_('Position ID'),
_('Status'),
_('Email'),
_('Order date'),
_('Product'),
_('Variation'),
_('Price'),
_('Tax rate'),
_('Tax rule'),
_('Tax value'),
_('Attendee name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
if len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(_('Attendee name') + ': ' + str(label))
headers += [
_('Attendee email'),
_('Voucher'),
_('Pseudonymization ID'),
]
questions = list(self.event.questions.all())
for q in questions:
headers.append(str(q.question))
headers += [
_('Company'),
_('Invoice address name'),
]
if len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label))
headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
]
yield headers
for op in qs.order_by('order__datetime', 'positionid'):
order = op.order
row = [
order.code,
op.positionid,
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
str(op.item),
str(op.variation) if op.variation else '',
op.price,
op.tax_rate,
str(op.tax_rule) if op.tax_rule else '',
op.tax_value,
op.attendee_name,
]
if len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
op.attendee_name_parts.get(k, '')
)
row += [
op.attendee_email,
op.voucher.code if op.voucher else '',
op.pseudonymization_id,
]
acache = {}
for a in op.answers.all():
acache[a.question_id] = str(a)
for q in questions:
row.append(acache.get(q.pk, ''))
try:
row += [
order.invoice_address.company,
order.invoice_address.name,
]
if len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
order.invoice_address.name_parts.get(k, '')
)
row += [
order.invoice_address.street,
order.invoice_address.zipcode,
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
yield row
def get_filename(self):
return '{}_orders'.format(self.event.slug)
class PaymentListExporter(ListExporter):
identifier = 'paymentlist'
verbose_name = ugettext_lazy('List of payments and refunds')
verbose_name = ugettext_lazy('Order payments and refunds')
@property
def additional_form_fields(self):
@@ -208,7 +401,7 @@ class PaymentListExporter(ListExporter):
headers = [
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Amount'), _('Payment method')
_('Status code'), _('Amount'), _('Payment method')
]
yield headers
@@ -225,6 +418,7 @@ class PaymentListExporter(ListExporter):
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
d2,
obj.get_state_display(),
obj.state,
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
provider_names.get(obj.provider, obj.provider)
]
@@ -263,6 +457,176 @@ class QuotaListExporter(ListExporter):
return '{}_quotas'.format(self.event.slug)
class InvoiceDataExporter(MultiSheetListExporter):
identifier = 'invoicedata'
verbose_name = ugettext_lazy('Invoice data')
@property
def sheets(self):
return (
('invoices', _('Invoices')),
('lines', _('Invoice lines')),
)
def iterate_sheet(self, form_data, sheet):
if sheet == 'invoices':
yield [
_('Invoice number'),
_('Date'),
_('Order code'),
_('E-mail address'),
_('Invoice type'),
_('Cancellation of'),
_('Language'),
_('Invoice sender:') + ' ' + _('Name'),
_('Invoice sender:') + ' ' + _('Address'),
_('Invoice sender:') + ' ' + _('ZIP code'),
_('Invoice sender:') + ' ' + _('City'),
_('Invoice sender:') + ' ' + _('Country'),
_('Invoice sender:') + ' ' + _('Tax ID'),
_('Invoice sender:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Company'),
_('Invoice recipient:') + ' ' + _('Name'),
_('Invoice recipient:') + ' ' + _('Street address'),
_('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'),
_('Reverse charge'),
_('Shown foreign currency'),
_('Foreign currency rate'),
_('Total value (with taxes)'),
_('Total value (without taxes)'),
]
qs = self.event.invoices.order_by('full_invoice_no').select_related(
'order', 'refers'
).annotate(
total_gross=Subquery(
InvoiceLine.objects.filter(
invoice=OuterRef('pk')
).order_by().values('invoice').annotate(
s=Sum('gross_value')
).values('s')
),
total_net=Subquery(
InvoiceLine.objects.filter(
invoice=OuterRef('pk')
).order_by().values('invoice').annotate(
s=Sum(F('gross_value') - F('tax_value'))
).values('s')
)
)
for i in qs:
yield [
i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
i.order.email,
_('Cancellation') if i.is_cancellation else _('Invoice'),
i.refers.full_invoice_no if i.refers else '',
i.locale,
i.invoice_from_name,
i.invoice_from,
i.invoice_from_zipcode,
i.invoice_from_city,
i.invoice_from_country,
i.invoice_from_tax_id,
i.invoice_from_vat_id,
i.invoice_to_company,
i.invoice_to_name,
i.invoice_to_street or i.invoice_to,
i.invoice_to_zipcode,
i.invoice_to_city,
i.invoice_to_country,
i.invoice_to_vat_id,
i.invoice_to_beneficiary,
i.internal_reference,
_('Yes') if i.reverse_charge else _('No'),
i.foreign_currency_display,
i.foreign_currency_rate,
i.total_gross if i.total_gross else Decimal('0.00'),
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
]
elif sheet == 'lines':
yield [
_('Invoice number'),
_('Line number'),
_('Description'),
_('Gross price'),
_('Net price'),
_('Tax value'),
_('Tax rate'),
_('Tax name'),
_('Event start date'),
_('Date'),
_('Order code'),
_('E-mail address'),
_('Invoice type'),
_('Cancellation of'),
_('Invoice sender:') + ' ' + _('Name'),
_('Invoice sender:') + ' ' + _('Address'),
_('Invoice sender:') + ' ' + _('ZIP code'),
_('Invoice sender:') + ' ' + _('City'),
_('Invoice sender:') + ' ' + _('Country'),
_('Invoice sender:') + ' ' + _('Tax ID'),
_('Invoice sender:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Company'),
_('Invoice recipient:') + ' ' + _('Name'),
_('Invoice recipient:') + ' ' + _('Street address'),
_('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'),
]
qs = InvoiceLine.objects.filter(
invoice__event=self.event
).order_by('invoice__full_invoice_no', 'position').select_related(
'invoice', 'invoice__order', 'invoice__refers'
)
for l in qs:
i = l.invoice
yield [
i.full_invoice_no,
l.position + 1,
l.description.replace("<br />", " - "),
l.gross_value,
l.net_value,
l.tax_value,
l.tax_rate,
l.tax_name,
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
i.order.email,
_('Cancellation') if i.is_cancellation else _('Invoice'),
i.refers.full_invoice_no if i.refers else '',
i.invoice_from_name,
i.invoice_from,
i.invoice_from_zipcode,
i.invoice_from_city,
i.invoice_from_country,
i.invoice_from_tax_id,
i.invoice_from_vat_id,
i.invoice_to_company,
i.invoice_to_name,
i.invoice_to_street or i.invoice_to,
i.invoice_to_zipcode,
i.invoice_to_city,
i.invoice_to_country,
i.invoice_to_vat_id,
i.invoice_to_beneficiary,
i.internal_reference,
]
def get_filename(self):
return '{}_invoices'.format(self.event.slug)
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
def register_orderlist_exporter(sender, **kwargs):
return OrderListExporter
@@ -276,3 +640,8 @@ def register_paymentlist_exporter(sender, **kwargs):
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
def register_quotalist_exporter(sender, **kwargs):
return QuotaListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
def register_invoicedata_exporter(sender, **kwargs):
return InvoiceDataExporter

View File

@@ -1,9 +1,11 @@
import logging
import i18nfield.forms
from django import forms
from django.forms.models import ModelFormMetaclass
from django.utils import six
from django.utils.crypto import get_random_string
from formtools.wizard.views import SessionWizardView
from hierarkey.forms import HierarkeyForm
from pretix.base.models import Event
@@ -71,3 +73,29 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, name.split('.')[-1])
# TODO: make sure pub is always correct
return 'pub/' + fname
class PrefixForm(forms.Form):
prefix = forms.CharField(widget=forms.HiddenInput)
class SafeSessionWizardView(SessionWizardView):
def get_prefix(self, request, *args, **kwargs):
if hasattr(request, '_session_wizard_prefix'):
return request._session_wizard_prefix
prefix_form = PrefixForm(self.request.POST, prefix=super().get_prefix(request, *args, **kwargs))
if not prefix_form.is_valid():
request._session_wizard_prefix = get_random_string(length=24)
else:
request._session_wizard_prefix = prefix_form.cleaned_data['prefix']
return request._session_wizard_prefix
def get_context_data(self, form, **kwargs):
context = super().get_context_data(form=form, **kwargs)
context['wizard']['prefix_form'] = PrefixForm(
prefix=super().get_prefix(self.request),
initial={
'prefix': self.get_prefix(self.request)
}
)
return context

View File

@@ -120,7 +120,7 @@ class RegistrationForm(forms.Form):
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(email=email).exists():
if User.objects.filter(email__iexact=email).exists():
raise forms.ValidationError(
self.error_messages['duplicate_email'],
code='duplicate_email'

View File

@@ -275,10 +275,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
class Meta:
model = InvoiceAddress
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
'internal_reference')
'internal_reference', 'beneficiary')
widgets = {
'is_business': BusinessBooleanRadio,
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
'beneficiary': forms.Textarea(attrs={'rows': 3}),
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'internal_reference': forms.TextInput,
@@ -291,17 +292,18 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.event = event = kwargs.pop('event')
self.request = kwargs.pop('request', None)
self.validate_vat_id = kwargs.pop('validate_vat_id')
self.all_optional = kwargs.pop('all_optional', False)
super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid:
del self.fields['vat_id']
if not event.settings.invoice_address_required:
if not event.settings.invoice_address_required or self.all_optional:
for k, f in self.fields.items():
f.required = False
f.widget.is_required = False
if 'required' in f.widget.attrs:
del f.widget.attrs['required']
elif event.settings.invoice_address_company_required:
elif event.settings.invoice_address_company_required and not self.all_optional:
self.initial['is_business'] = True
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
@@ -314,16 +316,19 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=event.settings.invoice_name_required,
required=event.settings.invoice_name_required and not self.all_optional,
scheme=event.settings.name_scheme,
label=_('Name'),
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
)
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required:
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
if not event.settings.invoice_address_beneficiary:
del self.fields['beneficiary']
def clean(self):
data = self.cleaned_data
if not data.get('is_business'):

View File

@@ -69,7 +69,7 @@ class UserSettingsForm(forms.ModelForm):
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',

View File

@@ -90,6 +90,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
time_attrs = dict(attrs)
date_attrs.setdefault('class', 'form-control splitdatetimepart')
time_attrs.setdefault('class', 'form-control splitdatetimepart')
date_attrs.setdefault('autocomplete', 'off')
time_attrs.setdefault('autocomplete', 'off')
date_attrs['class'] += ' datepickerfield'
time_attrs['class'] += ' timepickerfield'

View File

@@ -4,11 +4,12 @@ from decimal import Decimal
from io import BytesIO
from typing import Tuple
import bleach
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 pgettext
from django.utils.translation import pgettext, ugettext
from PIL.Image import BICUBIC
from reportlab.lib import pagesizes
from reportlab.lib.enums import TA_LEFT
@@ -31,6 +32,31 @@ from pretix.base.templatetags.money import money_filter
logger = logging.getLogger(__name__)
class NumberedCanvas(Canvas):
def __init__(self, *args, **kwargs):
self.font_regular = kwargs.pop('font_regular')
super().__init__(*args, **kwargs)
self._saved_page_states = []
def showPage(self):
self._saved_page_states.append(dict(self.__dict__))
self._startPage()
def save(self):
num_pages = len(self._saved_page_states)
for state in self._saved_page_states:
self.__dict__.update(state)
self.draw_page_number(num_pages)
Canvas.showPage(self)
Canvas.save(self)
def draw_page_number(self, page_count):
self.saveState()
self.setFont(self.font_regular, 8)
self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,))
self.restoreState()
class BaseInvoiceRenderer:
"""
This is the base class for all invoice renderers.
@@ -79,6 +105,9 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
top_margin = 20 * mm
bottom_margin = 15 * mm
doc_template_class = BaseDocTemplate
canvas_class = Canvas
font_regular = 'OpenSans'
font_bold = 'OpenSansBd'
def _init(self):
"""
@@ -92,10 +121,10 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles.
"""
stylesheet = StyleSheet1()
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName='OpenSansBd', fontSize=8, leading=12))
stylesheet.add(ParagraphStyle(name='Fineprint', fontName='OpenSans', fontSize=8, leading=10))
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
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))
return stylesheet
def _register_fonts(self):
@@ -171,7 +200,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
)
])
story = self._get_story(doc)
doc.build(story)
doc.build(story, canvasmaker=self.canvas_class)
return doc
def generate(self, invoice: Invoice):
@@ -206,10 +235,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
identifier = 'classic'
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
def canvas_class(self, *args, **kwargs):
kwargs['font_regular'] = self.font_regular
return NumberedCanvas(*args, **kwargs)
def _on_other_page(self, canvas: Canvas, doc):
canvas.saveState()
canvas.setFont('OpenSans', 8)
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
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())
@@ -233,72 +265,78 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
canvas.saveState()
canvas.setFont('OpenSans', 8)
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
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())
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
canvas.drawText(textobject)
self._draw_invoice_from(canvas)
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
canvas.drawText(textobject)
self._draw_invoice_to(canvas)
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Order code').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.order.full_code)
canvas.drawText(textobject)
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
if self.invoice.is_cancellation:
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
textobject.moveCursor(0, 5)
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.refers.number)
else:
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
textobject.moveCursor(0, 5)
if self.invoice.is_cancellation:
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.setFont(self.font_regular, 10)
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
textobject.moveCursor(0, 5)
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.setFont(self.font_regular, 10)
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
textobject.moveCursor(0, 5)
else:
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.setFont(self.font_regular, 10)
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
textobject.moveCursor(0, 5)
@@ -349,7 +387,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
textobject.setFont('OpenSansBd', 8)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Event').upper())
canvas.drawText(textobject)
@@ -390,6 +428,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(Paragraph(
pgettext('invoice', 'Beneficiary') + ':<br />' +
bleach.clean(self.invoice.invoice_to_beneficiary, tags=[]).replace("\n", "<br />\n"),
self.stylesheet['Normal']
))
if self.invoice.introductory_text:
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
story.append(Spacer(1, 10 * mm))
@@ -400,8 +445,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
tstyledata = [
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
('FONTNAME', (0, 0), (-1, 0), self.font_bold),
('FONTNAME', (0, -1), (-1, -1), self.font_bold),
('LEFTPADDING', (0, 0), (0, -1), 0),
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
]
@@ -469,7 +514,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
('LEFTPADDING', (0, 0), (0, -1), 0),
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
('FONTSIZE', (0, 0), (-1, -1), 8),
('FONTNAME', (0, 0), (-1, -1), 'OpenSans'),
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
]
thead = [
pgettext('invoice', 'Tax rate'),

View File

@@ -129,13 +129,22 @@ def get_language_from_request(request: HttpRequest) -> str:
if _supported is None:
_supported = OrderedDict(settings.LANGUAGES)
return (
get_language_from_user_settings(request)
or get_language_from_session_or_cookie(request)
or get_language_from_browser(request)
or get_language_from_event(request)
or get_default_language()
)
if request.path.startswith(get_script_prefix() + 'control'):
return (
get_language_from_user_settings(request)
or get_language_from_session_or_cookie(request)
or get_language_from_browser(request)
or get_language_from_event(request)
or get_default_language()
)
else:
return (
get_language_from_session_or_cookie(request)
or get_language_from_user_settings(request)
or get_language_from_browser(request)
or get_language_from_event(request)
or get_default_language()
)
def _parse_csp(header):
@@ -189,6 +198,7 @@ class SecurityMiddleware(MiddlewareMixin):
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
'font-src': ["{static}"],
'media-src': ["{static}", "data:"],
# form-action is not only used to match on form actions, but also on URLs
# form-actions redirect to. In the context of e.g. payment providers or
# single-sign-on this can be nearly anything so we cannot really restrict

View File

@@ -0,0 +1,62 @@
# Generated by Django 2.1.1 on 2018-11-14 15:26
import django.db.models.deletion
import django.db.models.manager
import jsonfallback.fields
from django.db import migrations, models
def change_refunded_to_canceled(apps, schema_editor):
Order = apps.get_model('pretixbase', 'Order')
Order.objects.filter(status='r').update(status='c')
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0103_auto_20181121_1224'),
]
operations = [
migrations.AlterModelManagers(
name='orderposition',
managers=[
('all', django.db.models.manager.Manager()),
],
),
migrations.AddField(
model_name='orderposition',
name='canceled',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='orderfee',
name='fee_type',
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('cancellation', 'Cancellation fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100),
),
migrations.AlterField(
model_name='orderposition',
name='order',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_positions',
to='pretixbase.Order', verbose_name='Order'),
),
migrations.AlterModelManagers(
name='orderfee',
managers=[
('all', django.db.models.manager.Manager()),
],
),
migrations.AddField(
model_name='orderfee',
name='canceled',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='orderfee',
name='order',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_fees', to='pretixbase.Order', verbose_name='Order'),
),
migrations.RunPython(
change_refunded_to_canceled, migrations.RunPython.noop
)
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 2.1 on 2019-01-12 15:12
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', '0104_auto_20181114_1526'),
]
operations = [
migrations.AddField(
model_name='invoiceaddress',
name='beneficiary',
field=models.TextField(blank=True, verbose_name='Beneficiary'),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_beneficiary',
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
),
]

View File

@@ -0,0 +1,54 @@
# Generated by Django 2.1.5 on 2019-02-04 13:02
import django.db.migrations.operations.special
from django.db import migrations, models
def enable_notifications_for_everyone(apps, schema_editor):
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
User = apps.get_model('pretixbase', 'User')
create = []
for u in User.objects.iterator():
create.append(NotificationSetting(
user=u,
action_type='pretix.event.order.refund.requested',
event=None,
method='mail',
enabled=True
))
if len(create) > 200:
NotificationSetting.objects.bulk_create(create)
create.clear()
NotificationSetting.objects.bulk_create(create)
create.clear()
class Migration(migrations.Migration):
replaces = [('pretixbase', '0105_auto_20190112_1512'), ('pretixbase', '0106_auto_20190118_1527'),
('pretixbase', '0107_auto_20190129_1337')]
dependencies = [
('pretixbase', '0104_auto_20181114_1526'),
]
operations = [
migrations.AddField(
model_name='invoiceaddress',
name='beneficiary',
field=models.TextField(blank=True, verbose_name='Beneficiary'),
),
migrations.AddField(
model_name='invoice',
name='invoice_to_beneficiary',
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
),
migrations.RunPython(
code=enable_notifications_for_everyone,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AlterField(
model_name='order',
name='datetime',
field=models.DateTimeField(db_index=True, verbose_name='Date'),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 2.1.5 on 2019-01-18 15:27
from django.db import migrations
def enable_notifications_for_everyone(apps, schema_editor):
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
User = apps.get_model('pretixbase', 'User')
create = []
for u in User.objects.iterator():
create.append(NotificationSetting(
user=u,
action_type='pretix.event.order.refund.requested',
event=None,
method='mail',
enabled=True
))
if len(create) > 200:
NotificationSetting.objects.bulk_create(create)
create.clear()
NotificationSetting.objects.bulk_create(create)
create.clear()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0105_auto_20190112_1512'),
]
operations = [
migrations.RunPython(enable_notifications_for_everyone, migrations.RunPython.noop)
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 2.1.5 on 2019-01-29 13:37
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', '0106_auto_20190118_1527'),
]
operations = [
migrations.AlterField(
model_name='order',
name='datetime',
field=models.DateTimeField(db_index=True, verbose_name='Date'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 2.1.5 on 2019-02-01 15:27
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', '0107_auto_20190129_1337'),
]
operations = [
migrations.AddField(
model_name='item',
name='generate_tickets',
field=models.NullBooleanField(verbose_name='Allow ticket download'),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 2.1 on 2019-02-08 14:32
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', '0108_auto_20190201_1527'),
]
operations = [
migrations.AddField(
model_name='invoiceline',
name='event_date_from',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='invoiceline',
name='subevent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.1.5 on 2019-02-19 12:45
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', '0109_auto_20190208_1432'),
]
operations = [
migrations.AlterField(
model_name='event',
name='plugins',
field=models.TextField(blank=True, default='', verbose_name='Plugins'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 2.1.5 on 2019-02-19 09:49
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', '0110_auto_20190219_1245'),
]
operations = [
migrations.AddField(
model_name='order',
name='testmode',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='event',
name='testmode',
field=models.BooleanField(default=False),
),
]

View File

@@ -114,7 +114,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
def save(self, *args, **kwargs):
self.email = self.email.lower()
is_new = not self.pk
super().save(*args, **kwargs)
if is_new:
self.notification_settings.create(
action_type='pretix.event.order.refund.requested',
event=None,
method='mail',
enabled=True
)
def __str__(self):
return self.email

View File

@@ -76,8 +76,17 @@ class LoggingMixin:
kwargs['api_token'] = api_token
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, **kwargs)
if data:
if isinstance(data, dict):
sensitivekeys = ['password', 'secret', 'api_key']
for sensitivekey in sensitivekeys:
for k, v in data.items():
if (sensitivekey in k) and v:
data[k] = "********"
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
elif data:
raise TypeError("You should only supply dictionaries as log data.")
if save:
logentry.save()

View File

@@ -20,6 +20,9 @@ class CheckinList(LoggedModel):
'order have not been paid. This only works with pretixdesk '
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
class Meta:
ordering = ('subevent__date_from', 'name')
@staticmethod
def annotate_with_numbers(qs, event):
"""

View File

@@ -11,7 +11,7 @@ from django.core.files.storage import default_storage
from django.core.mail import get_connection
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Exists, OuterRef, Q
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
from django.template.defaultfilters import date as _date
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -22,6 +22,7 @@ from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.validators import EventSlugBlacklistValidator
from pretix.helpers.database import GroupConcat
from pretix.helpers.daterange import daterange
from pretix.helpers.json import safe_string
@@ -159,6 +160,79 @@ class EventMixin:
return safe_string(json.dumps(eventdict))
@classmethod
def annotated(cls, qs, channel='web'):
from pretix.base.models import Item, ItemVariation, Quota
sq_active_item = Item.objects.filter_available(channel=channel).filter(
Q(variations__isnull=True)
& Q(quotas__pk=OuterRef('pk'))
).order_by().values_list('quotas__pk').annotate(
items=GroupConcat('pk', delimiter=',')
).values('items')
sq_active_variation = ItemVariation.objects.filter(
Q(active=True)
& Q(item__active=True)
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
& Q(item__sales_channels__contains=channel)
& Q(item__hide_without_voucher=False) # TODO: does this make sense?
& Q(quotas__pk=OuterRef('pk'))
).order_by().values_list('quotas__pk').annotate(
items=GroupConcat('pk', delimiter=',')
).values('items')
return qs.prefetch_related(
Prefetch(
'quotas',
to_attr='active_quotas',
queryset=Quota.objects.annotate(
active_items=Subquery(sq_active_item, output_field=models.TextField()),
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
).exclude(
Q(active_items="") & Q(active_variations="")
).select_related('event', 'subevent')
)
)
@cached_property
def best_availability_state(self):
from .items import Quota
if not hasattr(self, 'active_quotas'):
raise TypeError("Call this only if you fetched the subevents via Event/SubEvent.annotated()")
items_available = set()
vars_available = set()
items_reserved = set()
vars_reserved = set()
items_gone = set()
vars_gone = set()
for q in self.active_quotas:
res = q.availability(allow_cache=True)
if res[0] == Quota.AVAILABILITY_OK:
if q.active_items:
items_available.update(q.active_items.split(","))
if q.active_variations:
vars_available.update(q.active_variations.split(","))
elif res[0] == Quota.AVAILABILITY_RESERVED:
if q.active_items:
items_reserved.update(q.active_items.split(","))
if q.active_variations:
vars_available.update(q.active_variations.split(","))
elif res[0] < Quota.AVAILABILITY_RESERVED:
if q.active_items:
items_gone.update(q.active_items.split(","))
if q.active_variations:
vars_gone.update(q.active_variations.split(","))
if not self.active_quotas:
return None
if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone:
return Quota.AVAILABILITY_OK
if items_reserved - items_gone or vars_reserved - vars_gone:
return Quota.AVAILABILITY_RESERVED
return Quota.AVAILABILITY_GONE
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
class Event(EventMixin, LoggedModel):
@@ -168,6 +242,8 @@ class Event(EventMixin, LoggedModel):
:param organizer: The organizer this event belongs to
:type organizer: Organizer
:param testmode: This event is in test mode
:type testmode: bool
:param name: This event's full title
:type name: str
:param slug: A short, alphanumeric, all-lowercase name for use in URLs. The slug has to
@@ -197,6 +273,7 @@ class Event(EventMixin, LoggedModel):
settings_namespace = 'event'
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT)
testmode = models.BooleanField(default=False)
name = I18nCharField(
max_length=200,
verbose_name=_("Event name"),
@@ -247,7 +324,7 @@ class Event(EventMixin, LoggedModel):
verbose_name=_("Location"),
)
plugins = models.TextField(
null=True, blank=True,
null=False, blank=True,
verbose_name=_("Plugins"),
)
comment = models.TextField(
@@ -281,10 +358,11 @@ class Event(EventMixin, LoggedModel):
if not really:
raise TypeError("Pass really=True as a parameter.")
OrderPosition.objects.filter(order__event=self).delete()
OrderPosition.all.filter(order__event=self, addon_to__isnull=False).delete()
OrderPosition.all.filter(order__event=self).delete()
OrderFee.objects.filter(order__event=self).delete()
OrderPayment.objects.filter(order__event=self).delete()
OrderRefund.objects.filter(order__event=self).delete()
OrderPayment.objects.filter(order__event=self).delete()
self.orders.all().delete()
def save(self, *args, **kwargs):
@@ -468,6 +546,7 @@ class Event(EventMixin, LoggedModel):
else:
s.save()
self.settings.flush()
event_copy_data.send(
sender=self, other=other,
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
@@ -572,8 +651,10 @@ class Event(EventMixin, LoggedModel):
)
).order_by('date_from', 'name')
@property
def subevent_list_subevents(self):
def subevents_annotated(self, channel):
return SubEvent.annotated(self.subevents, channel)
def subevents_sorted(self, queryset):
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
orderfields = {
'date_ascending': ('date_from', 'name'),
@@ -581,7 +662,7 @@ class Event(EventMixin, LoggedModel):
'name_ascending': ('name', 'date_from'),
'name_descending': ('-name', 'date_from'),
}[ordering]
subevs = self.subevents.filter(
subevs = queryset.filter(
Q(active=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(date_to__gte=now())
@@ -674,6 +755,7 @@ class Event(EventMixin, LoggedModel):
return not self.orders.exists() and not self.invoices.exists()
def delete_sub_objects(self):
self.cartposition_set.all().delete()
self.items.all().delete()
self.subevents.all().delete()
@@ -682,7 +764,7 @@ class Event(EventMixin, LoggedModel):
plugins_active = self.get_plugins()
plugins_available = {
p.module: p for p in get_all_plugins()
p.module: p for p in get_all_plugins(self)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}

View File

@@ -91,6 +91,7 @@ class Invoice(models.Model):
invoice_to_city = models.TextField(null=True)
invoice_to_country = CountryField(null=True)
invoice_to_vat_id = models.TextField(null=True)
invoice_to_beneficiary = models.TextField(null=True)
date = models.DateField(default=today)
locale = models.CharField(max_length=50, default='en')
introductory_text = models.TextField(blank=True)
@@ -117,8 +118,8 @@ class Invoice(models.Model):
self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
str(self.invoice_from_country),
pgettext("invoice", "VAT-ID: %s" % self.invoice_from_vat_id) if self.invoice_from_vat_id else "",
pgettext("invoice", "Tax ID: %s" % self.invoice_from_tax_id) if self.invoice_from_tax_id else "",
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()])
@@ -149,6 +150,8 @@ class Invoice(models.Model):
if not self.prefix:
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
if not self.invoice_no:
if self.order.testmode:
self.prefix += 'TEST-'
for i in range(10):
if self.event.settings.get('invoice_numbers_consecutive'):
self.invoice_no = self._get_numeric_invoice_number()
@@ -191,6 +194,9 @@ class Invoice(models.Model):
unique_together = ('organizer', 'prefix', 'invoice_no')
ordering = ('date', 'invoice_no',)
def __repr__(self):
return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)
class InvoiceLine(models.Model):
"""
@@ -208,6 +214,10 @@ class InvoiceLine(models.Model):
:type tax_rate: decimal.Decimal
:param tax_name: The name of the applied tax rate
:type tax_name: str
:param subevent: The subevent this line refers to
:type subevent: SubEvent
:param event_date_from: Event date of the (sub)event at the time the invoice was created
:type event_date_from: datetime
"""
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
position = models.PositiveIntegerField(default=0)
@@ -216,6 +226,8 @@ class InvoiceLine(models.Model):
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
tax_name = models.CharField(max_length=190)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
event_date_from = models.DateTimeField(null=True)
@property
def net_value(self):
@@ -223,3 +235,6 @@ class InvoiceLine(models.Model):
class Meta:
ordering = ('position', 'pk')
def __str__(self):
return 'Line {} of invoice {}'.format(self.position, self.invoice)

View File

@@ -153,6 +153,30 @@ class SubEventItemVariation(models.Model):
self.subevent.event.cache.clear()
class ItemQuerySet(models.QuerySet):
def filter_available(self, channel='web', voucher=None, allow_addons=False):
q = (
# IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated()
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(sales_channels__contains=channel)
)
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
qs = self.filter(q)
vouchq = Q(hide_without_voucher=False)
if voucher:
if voucher.item_id:
vouchq |= Q(pk=voucher.item_id)
qs = qs.filter(pk=voucher.item_id)
elif voucher.quota_id:
qs = qs.filter(quotas__in=[voucher.quota_id])
return qs.filter(vouchq)
class Item(LoggedModel):
"""
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
@@ -200,6 +224,8 @@ class Item(LoggedModel):
:type sales_channels: bool
"""
objects = ItemQuerySet.as_manager()
event = models.ForeignKey(
Event,
on_delete=models.PROTECT,
@@ -261,6 +287,10 @@ class Item(LoggedModel):
),
default=False
)
generate_tickets = models.NullBooleanField(
verbose_name=_("Generate tickets"),
blank=True, null=True,
)
position = models.IntegerField(
default=0
)
@@ -301,9 +331,8 @@ class Item(LoggedModel):
allow_cancel = models.BooleanField(
verbose_name=_('Allow product to be canceled'),
default=True,
help_text=_('If this is active and the general event settings allow it, orders containing this product can be '
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
'and you can cancel orders at all times, regardless of this setting')
help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, '
'orders containing this product can not be canceled by users but only by you.')
)
min_per_order = models.IntegerField(
verbose_name=_('Minimum amount per order'),
@@ -416,7 +445,7 @@ class Item(LoggedModel):
def allow_delete(self):
from pretix.base.models.orders import OrderPosition
return not OrderPosition.objects.filter(item=self).exists()
return not OrderPosition.all.filter(item=self).exists()
@cached_property
def has_variations(self):
@@ -930,6 +959,16 @@ class Quota(LoggedModel):
:type size: int
:param items: The set of :py:class:`Item` objects this quota applies to
:param variations: The set of :py:class:`ItemVariation` objects this quota applies to
This model keeps a cache of the quota availability that is used in places where up-to-date
data is not important. This cache might be out of date even though a more recent quota was
calculated. This is intentional to keep database writes low. Currently, the cached values
are written whenever the quota is being calculated throughout the system and the cache is
at least 120 seconds old or if the new value is qualitatively "better" than the cached one
(i.e. more free quota).
There's also a cronjob that refreshes the cache of every quota if there is any log entry in
the event that is newer than the quota's cached time.
"""
AVAILABILITY_GONE = 0
@@ -1012,6 +1051,15 @@ class Quota(LoggedModel):
This method is used to determine whether Items or ItemVariations belonging
to this quota should currently be available for sale.
:param count_waitinglist: Whether or not take waiting list reservations into account. Defaults
to ``True``.
:param _cache: A dictionary mapping quota IDs to availabilities. If this quota is already
contained in that dictionary, this value will be used. Otherwise, the dict
will be populated accordingly.
:param allow_cache: Allow for values to be returned from the longer-term cache, see also
the documentation of this model class. Only works if ``count_waitinglist`` is
set to ``True``.
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
and the second is the number of available tickets.
"""
@@ -1027,7 +1075,10 @@ class Quota(LoggedModel):
res = self._availability(now_dt, count_waitinglist)
self.event.cache.delete('item_quota_cache')
if count_waitinglist and not self.cache_is_hot(now_dt):
rewrite_cache = count_waitinglist and (
not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state
)
if rewrite_cache:
self.cached_availability_state = res[0]
self.cached_availability_number = res[1]
self.cached_availability_time = now_dt
@@ -1064,16 +1115,16 @@ class Quota(LoggedModel):
size_left -= self.count_blocking_vouchers(now_dt)
if size_left <= 0:
return Quota.AVAILABILITY_RESERVED, 0
size_left -= self.count_in_cart(now_dt)
if size_left <= 0:
return Quota.AVAILABILITY_RESERVED, 0
return Quota.AVAILABILITY_ORDERED, 0
if count_waitinglist:
size_left -= self.count_waiting_list_pending()
if size_left <= 0:
return Quota.AVAILABILITY_RESERVED, 0
return Quota.AVAILABILITY_ORDERED, 0
size_left -= self.count_in_cart(now_dt)
if size_left <= 0:
return Quota.AVAILABILITY_RESERVED, 0
return Quota.AVAILABILITY_OK, size_left

View File

@@ -28,6 +28,7 @@ from django_countries.fields import CountryField
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
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
@@ -70,12 +71,13 @@ class Order(LockModel, LoggedModel):
* ``STATUS_PAID``
* ``STATUS_EXPIRED``
* ``STATUS_CANCELED``
* ``STATUS_REFUNDED``
:param event: The event this order belongs to
:type event: Event
:param email: The email of the person who ordered this
:type email: str
:param testmode: Whether this is a test mode order
:type testmode: bool
:param locale: The locale of this order
:type locale: str
:param secret: A secret string that is required to modify the order
@@ -102,13 +104,12 @@ class Order(LockModel, LoggedModel):
STATUS_PAID = "p"
STATUS_EXPIRED = "e"
STATUS_CANCELED = "c"
STATUS_REFUNDED = "r"
STATUS_REFUNDED = "c" # deprecated
STATUS_CHOICE = (
(STATUS_PENDING, _("pending")),
(STATUS_PAID, _("paid")),
(STATUS_EXPIRED, _("expired")),
(STATUS_CANCELED, _("canceled")),
(STATUS_REFUNDED, _("refunded"))
)
code = models.CharField(
@@ -122,6 +123,7 @@ class Order(LockModel, LoggedModel):
verbose_name=_("Status"),
db_index=True
)
testmode = models.BooleanField(default=False)
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
@@ -138,7 +140,7 @@ class Order(LockModel, LoggedModel):
)
secret = models.CharField(max_length=32, default=generate_secret)
datetime = models.DateTimeField(
verbose_name=_("Date")
verbose_name=_("Date"), db_index=True
)
expires = models.DateTimeField(
verbose_name=_("Expiration date")
@@ -186,6 +188,45 @@ class Order(LockModel, LoggedModel):
def __str__(self):
return self.full_code
def gracefully_delete(self, user=None, auth=None):
if not self.testmode:
raise TypeError("Only test mode orders can be deleted.")
self.event.log_action(
'pretix.event.order.deleted', user=user, auth=auth,
data={
'code': self.code,
}
)
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
OrderPosition.all.filter(order=self).delete()
OrderFee.all.filter(order=self).delete()
self.refunds.all().delete()
self.payments.all().delete()
self.event.cache.delete('complain_testmode_orders')
self.delete()
@property
def fees(self):
"""
Related manager for all non-canceled fees. Use ``all_fees`` instead if you want
canceled positions as well.
"""
return self.all_fees(manager='objects')
@cached_property
def count_positions(self):
if hasattr(self, 'pcnt'):
return self.pcnt or 0
return self.positions.count()
@property
def positions(self):
"""
Related manager for all non-canceled positions. Use ``all_positions`` instead if you want
canceled positions as well.
"""
return self.all_positions(manager='objects')
@cached_property
def meta_info_data(self):
try:
@@ -207,8 +248,8 @@ class Order(LockModel, LoggedModel):
@property
def pending_sum(self):
total = self.total
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
total = 0
if self.status == Order.STATUS_CANCELED:
total = Decimal('0.00')
payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
@@ -219,7 +260,7 @@ class Order(LockModel, LoggedModel):
return total - payment_sum + refund_sum
@classmethod
def annotate_overpayments(cls, qs):
def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False):
payment_sum = OrderPayment.objects.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
order=OuterRef('pk')
@@ -237,38 +278,47 @@ class Order(LockModel, LoggedModel):
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
order=OuterRef('pk')
)
payment_sum_sq = Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
refund_sum_sq = Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
if sums:
qs = qs.annotate(
payment_sum=payment_sum_sq,
refund_sum=refund_sum_sq,
)
qs = qs.annotate(
payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
has_external_refund=Exists(external_refund),
has_pending_refund=Exists(pending_refund),
).annotate(
pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
).annotate(
is_overpaid=Case(
When(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0),
then=Value('1')),
When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
),
is_pending_with_full_payment=Case(
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
& Q(require_approval=False),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
),
is_underpaid=Case(
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
)
pending_sum_t=F('total') - Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
pending_sum_rc=-1 * Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
)
if refunds:
qs = qs.annotate(
has_external_refund=Exists(external_refund),
has_pending_refund=Exists(pending_refund),
)
if results:
qs = qs.annotate(
is_overpaid=Case(
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
then=Value('1')),
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
),
is_pending_with_full_payment=Case(
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=1e-8)
& Q(require_approval=False),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
),
is_underpaid=Case(
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
)
)
return qs
@property
@@ -336,10 +386,112 @@ class Order(LockModel, LoggedModel):
def cancel_allowed(self):
return (
self.status == Order.STATUS_PENDING
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions
)
@cached_property
def user_cancel_deadline(self):
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
until = self.event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper)
else:
until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
if until:
if self.event.has_subevents:
return min([
until.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
])
else:
return until.datetime(self.event)
@cached_property
def user_cancel_fee(self):
fee = Decimal('0.00')
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
if self.event.settings.cancel_allow_user_paid_keep_fees:
fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
).aggregate(
s=Sum('value')
)['s'] or 0
return round_decimal(fee, self.event.currency)
@property
def user_cancel_allowed(self) -> bool:
"""
Returns whether or not this order can be canceled by the user.
"""
from .checkin import Checkin
positions = list(
self.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
).select_related('item')
)
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
if not cancelable or not positions:
return False
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
return False
if self.status == Order.STATUS_PENDING:
return self.event.settings.cancel_allow_user
elif self.status == Order.STATUS_PAID:
if self.total == Decimal('0.00'):
return self.event.settings.cancel_allow_user
return self.event.settings.cancel_allow_user_paid
return False
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
# Algorithm to choose which payments are to be refunded to create the least hassle
payments = payments or self.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED)
for p in payments:
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
p.propose_refund = Decimal('0.00')
p.available_amount = p.amount - p.refunded_amount
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
to_refund = amount
proposals = {}
while to_refund and unused_payments:
bigger = sorted([
p for p in unused_payments
if p.available_amount > to_refund
and p.partial_refund_possible
], key=lambda p: p.available_amount)
same = [
p for p in unused_payments
if p.available_amount == to_refund
and (p.full_refund_possible or p.partial_refund_possible)
]
smaller = sorted([
p for p in unused_payments
if p.available_amount < to_refund
and (p.full_refund_possible or p.partial_refund_possible)
], key=lambda p: p.available_amount, reverse=True)
if same:
payment = same[0]
proposals[payment] = payment.available_amount
to_refund -= payment.available_amount
unused_payments.remove(payment)
elif bigger:
payment = bigger[0]
proposals[payment] = to_refund
to_refund -= to_refund
unused_payments.remove(payment)
elif smaller:
payment = smaller[0]
proposals[payment] = payment.available_amount
to_refund -= payment.available_amount
unused_payments.remove(payment)
else:
break
return proposals
@staticmethod
def normalize_code(code):
tr = str.maketrans({
@@ -358,6 +510,10 @@ class Order(LockModel, LoggedModel):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True:
code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset)
if self.testmode:
# Subtle way to recognize test orders while debugging: They all contain a 0 at the second place,
# even though zeros are not used outside test mode.
code = code[0] + "0" + code[2:]
if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists():
self.code = code
return
@@ -392,15 +548,6 @@ class Order(LockModel, LoggedModel):
return False # nothing there to modify
@property
def can_user_cancel(self) -> bool:
"""
Returns whether or not this order can be canceled by the user.
"""
positions = self.positions.all().select_related('item')
cancelable = all([op.item.allow_cancel for op in positions])
return self.cancel_allowed() and self.event.settings.cancel_allow_user and cancelable
@property
def is_expired_by_time(self):
return (
@@ -538,6 +685,9 @@ class Order(LockModel, LoggedModel):
if not self.email:
return
for k, v in self.event.meta_data.items():
context['meta_' + k] = v
with language(self.locale):
recipient = self.email
try:
@@ -563,6 +713,13 @@ class Order(LockModel, LoggedModel):
}
)
@property
def positions_with_tickets(self):
for op in self.positions.all():
if not op.generate_ticket:
continue
yield op
def answerfile_name(instance, filename: str) -> str:
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
@@ -907,6 +1064,9 @@ class OrderPayment(models.Model):
class Meta:
ordering = ('local_id',)
def __str__(self):
return self.full_id
@property
def info_data(self):
"""
@@ -968,9 +1128,23 @@ class OrderPayment(models.Model):
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
self.state = self.PAYMENT_STATE_CONFIRMED
self.payment_date = now()
self.save()
with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
# Race condition detected, this payment is already confirmed
return
locked_instance.state = self.PAYMENT_STATE_CONFIRMED
locked_instance.payment_date = now()
locked_instance.info = self.info # required for backwards compatibility
locked_instance.save(update_fields=['state', 'payment_date', 'info'])
# Do a cheap manual "refresh from db" on non-complex fields
for field in self._meta.concrete_fields:
if not field.is_relation:
setattr(self, field.attname, getattr(locked_instance, field.attname))
self.refresh_from_db()
self.order.log_action('pretix.event.order.payment.confirmed', {
'local_id': self.local_id,
@@ -1199,6 +1373,9 @@ class OrderRefund(models.Model):
class Meta:
ordering = ('local_id',)
def __str__(self):
return self.full_id
@property
def info_data(self):
"""
@@ -1254,11 +1431,19 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs)
class ActivePositionManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(canceled=False)
class OrderFee(models.Model):
"""
An OrderFee object represents a fee that is added to the order total independently of
the actual positions. This might for example be a payment or a shipping fee.
The default ``OrderFee.objects`` manager only contains fees that are not ``canceled``. If
you ant all objects, you need to use ``OrderFee.all`` instead.
:param value: Gross price of this fee
:type value: Decimal
:param order: Order this fee is charged with
@@ -1275,16 +1460,20 @@ class OrderFee(models.Model):
:type tax_rule: TaxRule
:param tax_value: The tax amount included in the price
:type tax_value: Decimal
:param canceled: True, if this position is canceled and should no longer be regarded
:type canceled: bool
"""
FEE_TYPE_PAYMENT = "payment"
FEE_TYPE_SHIPPING = "shipping"
FEE_TYPE_SERVICE = "service"
FEE_TYPE_CANCELLATION = "cancellation"
FEE_TYPE_OTHER = "other"
FEE_TYPE_GIFTCARD = "giftcard"
FEE_TYPES = (
(FEE_TYPE_PAYMENT, _("Payment fee")),
(FEE_TYPE_SHIPPING, _("Shipping fee")),
(FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
(FEE_TYPE_OTHER, _("Other fees")),
(FEE_TYPE_GIFTCARD, _("Gift card")),
)
@@ -1296,7 +1485,7 @@ class OrderFee(models.Model):
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='fees',
related_name='all_fees',
on_delete=models.PROTECT
)
fee_type = models.CharField(
@@ -1317,6 +1506,10 @@ class OrderFee(models.Model):
max_digits=10, decimal_places=2,
verbose_name=_('Tax value')
)
canceled = models.BooleanField(default=False)
all = models.Manager()
objects = ActivePositionManager()
@property
def net_value(self):
@@ -1371,6 +1564,9 @@ class OrderPosition(AbstractPosition):
of a specified type (or variation). This has all properties of
AbstractPosition.
The default ``OrderPosition.objects`` manager only contains fees that are not ``canceled``. If
you ant all objects, you need to use ``OrderPosition.all`` instead.
:param order: The order this position is a part of
:type order: Order
:param positionid: A local ID of this position, counted for each order individually
@@ -1383,6 +1579,8 @@ class OrderPosition(AbstractPosition):
:type tax_value: Decimal
:param secret: The secret used for ticket QR codes
:type secret: str
:param canceled: True, if this position is canceled and should no longer be regarded
:type canceled: bool
:param pseudonymization_id: The QR code content for lead scanning
:type pseudonymization_id: str
"""
@@ -1390,7 +1588,7 @@ class OrderPosition(AbstractPosition):
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='positions',
related_name='all_positions',
on_delete=models.PROTECT
)
tax_rate = models.DecimalField(
@@ -1412,6 +1610,10 @@ class OrderPosition(AbstractPosition):
unique=True,
db_index=True
)
canceled = models.BooleanField(default=False)
all = models.Manager()
objects = ActivePositionManager()
class Meta:
verbose_name = _("Order position")
@@ -1422,6 +1624,15 @@ class OrderPosition(AbstractPosition):
def sort_key(self):
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
@property
def generate_ticket(self):
if self.item.generate_tickets is not None:
return self.item.generate_tickets
return (
(self.order.event.settings.ticket_download_addons or not self.addon_to_id) and
(self.event.settings.ticket_download_nonadm or self.item.admission)
)
@classmethod
def transform_cart_positions(cls, cp: List, order) -> list:
from . import Voucher
@@ -1492,7 +1703,7 @@ class OrderPosition(AbstractPosition):
self._calculate_tax()
self.order.touch()
if self.pk is None:
while OrderPosition.objects.filter(secret=self.secret).exists():
while OrderPosition.all.filter(secret=self.secret).exists():
self.secret = generate_position_secret()
if not self.pseudonymization_id:
@@ -1508,7 +1719,7 @@ class OrderPosition(AbstractPosition):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True:
code = get_random_string(length=10, allowed_chars=charset)
if not OrderPosition.objects.filter(pseudonymization_id=code).exists():
if not OrderPosition.all.filter(pseudonymization_id=code).exists():
self.pseudonymization_id = code
return
@@ -1596,6 +1807,10 @@ class InvoiceAddress(models.Model):
help_text=_('This reference will be printed on your invoice for your convenience.'),
blank=True
)
beneficiary = models.TextField(
verbose_name=_('Beneficiary'),
blank=True
)
def save(self, **kwargs):
if self.order:

View File

@@ -97,7 +97,7 @@ class TaxRule(LoggedModel):
return (
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
and not OrderPosition.objects.filter(tax_rule=self, order__event=self.event).exists()
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
and not self.event.items.filter(tax_rule=self).exists()
and self.event.settings.tax_rate_default != self
)

View File

@@ -13,6 +13,7 @@ from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Quota
from .orders import Order
def _generate_random_code(prefix=None):
@@ -182,7 +183,7 @@ class Voucher(LoggedModel):
return self.code
def allow_delete(self):
return self.redeemed == 0
return self.redeemed == 0 and not self.orderposition_set.exists()
def clean(self):
Voucher.clean_item_properties(
@@ -380,3 +381,11 @@ class Voucher(LoggedModel):
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
return p
return original_price
def distinct_orders(self):
"""
Return the list of orders where this voucher has been used.
Each order will appear at most once.
"""
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()

View File

@@ -159,7 +159,7 @@ class WaitingListEntry(LoggedModel):
@staticmethod
def clean_duplicate(email, item, variation, subevent, pk):
if WaitingListEntry.objects.filter(
item=item, variation=variation, email=email, voucher__isnull=True, subevent=subevent
item=item, variation=variation, email__iexact=email, voucher__isnull=True, subevent=subevent
).exclude(pk=pk).exists():
raise ValidationError(_('You are already on this waiting list! We will notify '
'you as soon as we have a ticket available for you.'))

View File

@@ -177,6 +177,7 @@ class ParametrizedOrderNotificationType(NotificationType):
n.add_attribute(_('Event'), order.event.name)
n.add_attribute(_('Order code'), order.code)
n.add_attribute(_('Order total'), money_filter(order.total, logentry.event.currency))
n.add_attribute(_('Pending amount'), money_filter(order.pending_sum, logentry.event.currency))
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))
n.add_attribute(_('Order status'), order.get_status_display())
n.add_attribute(_('Order positions'), str(order.positions.count()))
@@ -243,9 +244,9 @@ def register_default_notification_types(sender, **kwargs):
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.refunded',
_('Order refunded'),
_('Order {order.code} has been refunded.')
'pretix.event.order.refund.requested',
_('Refund requested'),
_('You have been requested to issue a refund for {order.code}.')
),
ActionRequiredNotificationType(
sender,

View File

@@ -88,6 +88,18 @@ class BasePaymentProvider:
"""
return self.settings.get('_enabled', as_type=bool)
@property
def test_mode_message(self) -> str:
"""
If this property is set to a string, this will be displayed when this payment provider is selected
while the event is in test mode. You should use it to explain to your user how your plugin behaves,
e.g. if it falls back to a test mode automatically as well or if actual payments will be performed.
If you do not set this (or, return ``None``), pretix will show a default message warning the user
that this plugin does not support test mode payments.
"""
return None
def calculate_fee(self, price: Decimal) -> Decimal:
"""
Calculate the fee for this payment provider which will be added to
@@ -713,6 +725,11 @@ class ManualPayment(BasePaymentProvider):
identifier = 'manual'
verbose_name = _('Manual payment')
@property
def test_mode_message(self):
return _('In test mode, you can just manually mark this order as paid in the backend after it has been '
'created.')
@property
def is_implicit(self):
return 'pretix.plugins.manualpayment' not in self.event.plugins
@@ -788,9 +805,9 @@ class ManualPayment(BasePaymentProvider):
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
return msg
def order_pending_render(self, request, order) -> str:
def payment_pending_render(self, request, payment) -> str:
return rich_text(
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order))
)

View File

@@ -14,6 +14,7 @@ from django.conf import settings
from django.contrib.staticfiles import finders
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from PyPDF2 import PdfFileReader
from pytz import timezone
@@ -192,6 +193,30 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Event organizer info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
}),
("now_date", {
"label": _("Printing date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format(
now().astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT"
)
}),
("now_datetime", {
"label": _("Printing date and time"),
"editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format(
now().astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT"
)
}),
("now_time", {
"label": _("Printing time"),
"editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format(
now().astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT"
) if ev.date_admission else ""
}),
))
@@ -219,6 +244,14 @@ def variables_from_questions(sender, *args, **kwargs):
return d
def _get_attendee_name_part(key, op, order, ev):
return op.attendee_name_parts.get(key, '')
def _get_ia_name_part(key, op, order, ev):
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES)
@@ -227,7 +260,7 @@ def get_variables(event):
v['attendee_name_%s' % key] = {
'label': _("Attendee name: {part}").format(part=label),
'editor_sample': scheme['sample'][key],
'evaluate': lambda op, order, ev: op.attendee_name_parts.get(key, '')
'evaluate': partial(_get_attendee_name_part, key)
}
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
@@ -237,7 +270,7 @@ def get_variables(event):
v['invoice_name_%s' % key] = {
'label': _("Invoice address name: {part}").format(part=label),
'editor_sample': scheme['sample'][key],
"evaluate": lambda op, order, ev: order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
"evaluate": partial(_get_ia_name_part, key)
}
for recv, res in layout_text_variables.send(sender=event):

View File

@@ -17,7 +17,7 @@ class PluginType(Enum):
EXPORT = 4
def get_all_plugins() -> List[type]:
def get_all_plugins(event=None) -> List[type]:
"""
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
"""
@@ -29,5 +29,13 @@ def get_all_plugins() -> List[type]:
meta.app = app
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
if hasattr(app, 'is_available') and event:
if not app.is_available(event):
continue
plugins.append(meta)
return plugins
return sorted(
plugins,
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
)

View File

@@ -143,10 +143,12 @@ class CartManager:
for cp in self.positions:
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
err = error_messages['some_subevent_not_started']
cp.addons.all().delete()
cp.delete()
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
err = error_messages['some_subevent_ended']
cp.addons.all().delete()
cp.delete()
return err
@@ -369,7 +371,7 @@ class CartManager:
# could cancel each other out quota-wise). However, we are not taking this performance
# penalty for now as there is currently no outside interface that would allow building
# such a transaction.
for cp in self.positions.all():
for cp in self.positions.filter(addon_to__isnull=True):
self._operations.append(self.RemoveOperation(position=cp))
def set_addons(self, addons):
@@ -651,6 +653,7 @@ class CartManager:
op.position.price = op.price.gross
op.position.save()
elif available_count == 0:
op.position.addons.all().delete()
op.position.delete()
else:
raise AssertionError("ExtendOperation cannot affect more than one item")
@@ -667,15 +670,15 @@ class CartManager:
self._check_max_cart_size()
self._calculate_expiry()
with self.event.lock() 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)
# with self.event.lock() as now_dt:
with transaction.atomic():
self.now_dt = now()
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)
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):

View File

@@ -78,7 +78,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
dt = datetime or now()
# Fetch order position with related objects
op = OrderPosition.objects.select_related(
op = OrderPosition.all.select_related(
'item', 'variation', 'order', 'addon_to'
).prefetch_related(
'item__questions',
@@ -90,10 +90,16 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'answers'
).get(pk=op.pk)
if op.canceled:
raise CheckInError(
_('This order position has been canceled.'),
'unpaid'
)
answers = {a.question: a for a in op.answers.all()}
require_answers = []
for q in op.item.checkin_questions:
if q not in given_answers:
if q not in given_answers and q not in answers:
require_answers.append(q)
_save_answers(op, answers, given_answers)

View File

@@ -81,6 +81,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.invoice_to_zipcode = ia.zipcode
invoice.invoice_to_city = ia.city
invoice.invoice_to_country = ia.country
invoice.invoice_to_beneficiary = ia.beneficiary
if ia.vat_id:
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
@@ -141,6 +142,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
InvoiceLine.objects.create(
position=i, invoice=invoice, description=desc,
gross_value=p.price, tax_value=p.tax_value,
subevent=p.subevent, event_date_from=(p.subevent.date_from if p.subevent else invoice.event.date_from),
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
)
@@ -234,7 +236,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
if trigger_pdf:
invoice_pdf(invoice.pk)
if order.status in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
if order.status == Order.STATUS_CANCELED:
generate_cancellation(invoice, trigger_pdf)
return invoice
@@ -310,6 +312,7 @@ def build_preview_invoice_pdf(event):
invoice.invoice_to_name, invoice.invoice_to_street,
invoice.invoice_to_zipcode, invoice.invoice_to_city
)
invoice.invoice_to_beneficiary = ''
invoice.file = None
invoice.save()
invoice.lines.all().delete()

View File

@@ -1,4 +1,5 @@
import logging
import smtplib
from email.utils import formataddr
from typing import Any, Dict, List, Union
@@ -78,6 +79,10 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
headers = headers or {}
with language(locale):
if isinstance(context, dict) and event:
for k, v in event.meta_data.items():
context['meta_' + k] = v
if isinstance(context, dict) and order:
try:
context.update({
@@ -125,6 +130,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
body_plain += "\r\n\r\n-- \r\n"
if order:
if order.testmode:
subject = "[TESTMODE] " + subject
body_plain += _(
"You are receiving this email because you placed an order for {event}."
).format(event=event.name)
@@ -170,8 +177,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
chain(*task_chain).apply_async()
@app.task
def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sender: str,
@app.task(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:
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
@@ -219,7 +226,34 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
try:
backend.send_messages([email])
except Exception:
except smtplib.SMTPResponseException as e:
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
logger.exception('Error sending email')
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'SMTP code {}'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
'recipient': '',
'invoices': [],
}
)
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'Internal error',
'message': str(e),
'recipient': '',
'invoices': [],
}
)
logger.exception('Error sending email')
raise SendMailException('Failed to send an email to {}.'.format(to))

View File

@@ -10,6 +10,7 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.db import transaction
from django.db.models import F, Max, Q, Sum
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
@@ -31,7 +32,7 @@ from pretix.base.models.orders import (
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TaxedPrice
from pretix.base.payment import BasePaymentProvider
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
)
@@ -124,25 +125,12 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
@transaction.atomic
def mark_order_refunded(order, user=None, auth=None, api_token=None):
"""
Mark this order as refunded. This sets the payment status and returns the order object.
:param order: The order to change
:param user: The user that performed the change
"""
if isinstance(order, int):
order = Order.objects.get(pk=order)
if isinstance(user, int):
user = User.objects.get(pk=user)
with order.event.lock():
order.status = Order.STATUS_REFUNDED
order.save(update_fields=['status'])
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
return order
oautha = auth.pk if isinstance(auth, OAuthApplication) else None
device = auth.pk if isinstance(auth, Device) else None
api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None)
return _cancel_order(
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha
)
@transaction.atomic
@@ -268,7 +256,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
if send_mail:
try:
@@ -306,7 +294,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
@transaction.atomic
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None):
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
cancellation_fee=None):
"""
Mark this order as canceled
:param order: The order to change
@@ -322,20 +311,54 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
device = Device.objects.get(pk=device)
if isinstance(oauth_application, int):
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
with order.event.lock():
if not order.cancel_allowed():
raise OrderError(_('You cannot cancel this order.'))
order.status = Order.STATUS_CANCELED
order.save(update_fields=['status'])
if isinstance(cancellation_fee, str):
cancellation_fee = Decimal(cancellation_fee)
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device)
if not order.cancel_allowed():
raise OrderError(_('You cannot cancel this order.'))
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
if cancellation_fee:
with order.event.lock():
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
position.canceled = True
position.save(update_fields=['canceled'])
for fee in order.fees.all():
fee.canceled = True
fee.save(update_fields=['canceled'])
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=cancellation_fee,
tax_rule=order.event.settings.tax_rate_default,
order=order,
)
f._calculate_tax()
f.save()
if order.payment_refund_sum < cancellation_fee:
raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
order.status = Order.STATUS_PAID
order.total = f.value
order.save(update_fields=['status', 'total'])
if i:
generate_invoice(order)
else:
with order.event.lock():
order.status = Order.STATUS_CANCELED
order.save(update_fields=['status'])
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
data={'cancellation_fee': cancellation_fee})
if send_mail:
email_template = order.event.settings.mail_text_order_canceled
@@ -510,6 +533,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
datetime=now_dt,
locale=locale,
total=total,
testmode=event.testmode,
meta_info=json.dumps(meta_info or {}),
require_approval=any(p.item.require_approval for p in positions),
sales_channel=sales_channel
@@ -532,7 +556,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
fee.tax_rule = None # TODO: deprecate
fee.save()
if payment_provider:
if payment_provider and not order.require_approval:
order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=payment_provider.identifier,
@@ -573,16 +597,16 @@ 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'))
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)
# with event.lock() as now_dt:
now_dt = now()
positions = list(CartPosition.objects.filter(id__in=position_ids).select_related('item', 'variation', 'subevent'))
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)
invoice = order.invoices.last() # Might be generated by plugin already
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
@@ -792,12 +816,17 @@ class OrderChangeManager:
self.notify = notify
self._invoice_dirty = False
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation], keep_price=False):
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation'])
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
invoice_address=self._invoice_address)
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'])
@@ -934,7 +963,7 @@ class OrderChangeManager:
)
self.order.save()
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
if self.order.pending_sum <= Decimal('0.00'):
if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval:
self.order.status = Order.STATUS_PAID
self.order.save()
elif self.open_payment:
@@ -954,7 +983,7 @@ class OrderChangeManager:
}, user=self.user, auth=self.auth)
def _check_paid_to_free(self):
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
# if the order becomes free, mark it paid using the 'free' provider
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
# or positions got split off to a new order (split_order with positive total)
@@ -969,7 +998,7 @@ class OrderChangeManager:
except Quota.QuotaExceededException:
raise OrderError(self.error_messages['paid_to_free_exceeded'])
if self.split_order and self.split_order.total == 0:
if self.split_order and self.split_order.total == 0 and not self.split_order.require_approval:
p = self.split_order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='free',
@@ -1043,7 +1072,10 @@ class OrderChangeManager:
'addon_to': opa.addon_to_id,
'old_price': opa.price,
})
opa.delete()
opa.canceled = True
if opa.voucher:
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
opa.save(update_fields=['canceled'])
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
@@ -1052,7 +1084,10 @@ class OrderChangeManager:
'old_price': op.position.price,
'addon_to': None,
})
op.position.delete()
op.position.canceled = True
if op.position.voucher:
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
op.position.save(update_fields=['canceled'])
elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
@@ -1091,6 +1126,7 @@ class OrderChangeManager:
split_order.code = None
split_order.datetime = now()
split_order.secret = generate_secret()
split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions)
split_order.save()
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
'original_order': self.order.code
@@ -1117,7 +1153,7 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist:
pass
split_order.total = sum([p.price for p in split_positions])
split_order.total = sum([p.price for p in split_positions if not p.canceled])
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
pp = self._get_payment_provider()
if pp:
@@ -1272,7 +1308,7 @@ class OrderChangeManager:
except SendMailException:
logger.exception('Order changed email could not be sent')
def commit(self):
def commit(self, check_quotas=True):
if self._committed:
# an order change can only be committed once
raise OrderError(error_messages['internal'])
@@ -1289,7 +1325,8 @@ class OrderChangeManager:
with self.order.event.lock():
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
raise OrderError(self.error_messages['not_pending_or_paid'])
self._check_quotas()
if check_quotas:
self._check_quotas()
self._check_complete_cancel()
self._perform_operations()
self._recalculate_total_and_payment_fee()
@@ -1338,10 +1375,59 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None):
device=None, cancellation_fee=None, try_auto_refund=False):
try:
try:
return _cancel_order(order, user, send_mail, api_token, device, oauth_application)
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
cancellation_fee)
if try_auto_refund:
notify_admin = False
error = False
order = Order.objects.get(pk=order)
refund_amount = order.pending_sum * -1
proposals = order.propose_auto_refunds(refund_amount)
can_auto_refund = sum(proposals.values()) == refund_amount
if can_auto_refund:
for p, value in proposals.items():
with transaction.atomic():
r = order.refunds.create(
payment=p,
source=OrderRefund.REFUND_SOURCE_BUYER,
state=OrderRefund.REFUND_STATE_CREATED,
amount=value,
provider=p.provider
)
order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
})
try:
r.payment_provider.execute_refund(r)
except PaymentException as e:
with transaction.atomic():
r.state = OrderRefund.REFUND_STATE_FAILED
r.save()
order.log_action('pretix.event.order.refund.failed', {
'local_id': r.local_id,
'provider': r.provider,
'error': str(e)
})
error = True
notify_admin = True
else:
if r.state != OrderRefund.REFUND_STATE_DONE:
notify_admin = True
elif refund_amount != Decimal('0.00'):
notify_admin = True
if notify_admin:
order.log_action('pretix.event.order.refund.requested')
if error:
raise OrderError(
_('There was an error while trying to send the money back to you. Please contact the event organizer for further information.')
)
return ret
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):

View File

@@ -1,6 +1,9 @@
from datetime import timedelta
from django.db import models
from django.db.models import F, Max, OuterRef, Q, Subquery
from django.dispatch import receiver
from django.utils.timezone import now
from pretix.base.models import LogEntry, Quota
from pretix.celery_app import app
@@ -26,7 +29,8 @@ def refresh_quota_caches():
last_activity=Subquery(last_activity, output_field=models.DateTimeField())
).filter(
Q(cached_availability_time__isnull=True) |
Q(cached_availability_time__lt=F('last_activity'))
Q(cached_availability_time__lt=F('last_activity')) |
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
)
for q in quotas:
q.availability()

View File

@@ -1,7 +1,7 @@
from decimal import Decimal
from typing import Any, Dict, Iterable, List, Tuple
from django.db.models import Count, Sum
from django.db.models import Case, Count, F, Sum, Value, When
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
@@ -79,18 +79,22 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
'variations'
).order_by('category__position', 'category_id', 'position', 'name')
qs = OrderPosition.objects
qs = OrderPosition.all
if subevent:
qs = qs.filter(subevent=subevent)
counters = qs.filter(
order__event=event
).annotate(
status=Case(
When(canceled=True, then=Value('c')),
default=F('order__status')
)
).values(
'item', 'variation', 'order__status'
'item', 'variation', 'status'
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
states = {
'canceled': Order.STATUS_CANCELED,
'refunded': Order.STATUS_REFUNDED,
'paid': Order.STATUS_PAID,
'pending': Order.STATUS_PENDING,
'expired': Order.STATUS_EXPIRED,
@@ -99,7 +103,7 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
for l, s in states.items():
num[l] = {
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
for p in counters if p['order__status'] == s
for p in counters if p['status'] == s
}
num['total'] = dictsum(num['pending'], num['paid'])
@@ -149,16 +153,21 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
payment_items = []
if not subevent:
counters = OrderFee.objects.filter(
counters = OrderFee.all.filter(
order__event=event
).annotate(
status=Case(
When(canceled=True, then=Value('c')),
default=F('order__status')
)
).values(
'fee_type', 'internal_type', 'order__status'
'fee_type', 'internal_type', 'status'
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()
for l, s in states.items():
num[l] = {
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
for o in counters if o['order__status'] == s
for o in counters if o['status'] == s
}
num['total'] = dictsum(num['pending'], num['paid'])

View File

@@ -117,6 +117,8 @@ def get_tickets_for_order(order):
if p.multi_download_enabled:
try:
if len(list(order.positions_with_tickets)) == 0:
continue
ct = CachedCombinedTicket.objects.filter(
order=order, provider=p.identifier, file__isnull=False
).last()
@@ -132,11 +134,7 @@ def get_tickets_for_order(order):
except:
logger.exception('Failed to generate ticket.')
else:
for pos in order.positions.all():
if pos.addon_to and not order.event.settings.ticket_download_addons:
continue
if not pos.item.admission and not order.event.settings.ticket_download_nonadm:
continue
for pos in order.positions_with_tickets:
try:
ct = CachedTicket.objects.filter(
order_position=pos, provider=p.identifier, file__isnull=False

View File

@@ -1,6 +1,7 @@
import json
from collections import OrderedDict
from datetime import datetime
from decimal import Decimal
from typing import Any
from django.conf import settings
@@ -64,6 +65,10 @@ DEFAULTS = {
'default': 'False',
'type': bool,
},
'invoice_address_beneficiary': {
'default': 'False',
'type': bool,
},
'invoice_address_vatid': {
'default': 'False',
'type': bool,
@@ -88,6 +93,10 @@ DEFAULTS = {
'default': '30',
'type': int
},
'payment_explanation': {
'default': '',
'type': LazyI18nString
},
'payment_term_days': {
'default': '14',
'type': int
@@ -204,6 +213,10 @@ DEFAULTS = {
'default': 'True',
'type': bool
},
'event_list_availability': {
'default': 'True',
'type': bool
},
'event_list_type': {
'default': 'list',
'type': str
@@ -216,6 +229,30 @@ DEFAULTS = {
'default': 'True',
'type': bool
},
'cancel_allow_user_until': {
'default': None,
'type': RelativeDateWrapper,
},
'cancel_allow_user_paid': {
'default': 'False',
'type': bool,
},
'cancel_allow_user_paid_keep': {
'default': '0.00',
'type': Decimal,
},
'cancel_allow_user_paid_keep_fees': {
'default': 'False',
'type': bool,
},
'cancel_allow_user_paid_keep_percentage': {
'default': '0.00',
'type': Decimal,
},
'cancel_allow_user_paid_until': {
'default': None,
'type': RelativeDateWrapper,
},
'contact_mail': {
'default': None,
'type': str

View File

@@ -133,12 +133,12 @@ class EmailAddressShredder(BaseDataShredder):
}, indent=4)
yield 'emails-by-attendee.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): op.attendee_email
for op in OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False)
for op in OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False)
}, indent=4)
@transaction.atomic
def shred_data(self):
OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
for o in self.event.orders.all():
o.email = None
@@ -202,7 +202,7 @@ class AttendeeNameShredder(BaseDataShredder):
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'attendee-names.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name
for op in OrderPosition.objects.filter(
for op in OrderPosition.all.filter(
order__event=self.event
).filter(
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
@@ -211,7 +211,7 @@ class AttendeeNameShredder(BaseDataShredder):
@transaction.atomic
def shred_data(self):
OrderPosition.objects.filter(
OrderPosition.all.filter(
order__event=self.event
).filter(
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
@@ -267,7 +267,7 @@ class QuestionAnswerShredder(BaseDataShredder):
def generate_files(self) -> List[Tuple[str, str, str]]:
yield 'question-answers.json', 'application/json', json.dumps({
'{}-{}'.format(op.order.code, op.positionid): AnswerSerializer(op.answers.all(), many=True).data
for op in OrderPosition.objects.filter(order__event=self.event).prefetch_related('answers')
for op in OrderPosition.all.filter(order__event=self.event).prefetch_related('answers')
}, indent=4)
@transaction.atomic

View File

@@ -65,11 +65,11 @@ class EventPluginSignal(django.dispatch.Signal):
if not app_cache:
_populate_app_cache()
for receiver in self._live_receivers(sender):
for receiver in self._sorted_receivers(sender):
if self._is_active(sender, receiver):
response = receiver(signal=self, sender=sender, **named)
responses.append((receiver, response))
return sorted(responses, key=lambda r: (receiver.__module__, receiver.__name__))
return responses
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
"""
@@ -89,12 +89,55 @@ class EventPluginSignal(django.dispatch.Signal):
if not app_cache:
_populate_app_cache()
for receiver in self._live_receivers(sender):
for receiver in self._sorted_receivers(sender):
if self._is_active(sender, receiver):
named[chain_kwarg_name] = response
response = receiver(signal=self, sender=sender, **named)
return response
def send_robust(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers. If a receiver raises an exception
instead of returning a value, the exception is included as the result instead of
stopping the response chain at the offending receiver.
sender is required to be an instance of ``pretix.base.models.Event``.
"""
if sender and not isinstance(sender, Event):
raise ValueError("Sender needs to be an event.")
responses = []
if (
not self.receivers
or self.sender_receivers_cache.get(sender) is NO_RECEIVERS
):
return []
if not app_cache:
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if self._is_active(sender, receiver):
try:
response = receiver(signal=self, sender=sender, **named)
except Exception as err:
responses.append((receiver, err))
else:
responses.append((receiver, response))
return responses
def _sorted_receivers(self, sender):
orig_list = self._live_receivers(sender)
sorted_list = sorted(
orig_list,
key=lambda receiver: (
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
receiver.__module__,
receiver.__name__,
)
)
return sorted_list
class DeprecatedSignal(django.dispatch.Signal):
@@ -183,7 +226,7 @@ register_sales_channels = django.dispatch.Signal(
)
"""
This signal is sent out to get all known sales channels types. Receivers should return an
instance of a subclass of pretix.base.channels.SalesChannel or a list of such
instance of a subclass of ``pretix.base.channels.SalesChannel`` or a list of such
instances.
"""

View File

@@ -1,13 +1,17 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "Bad Request" %}{% endblock %}
{% block content %}
<i class="fa fa-frown-o big-icon"></i>
<h1>{% trans "Bad Request" %}</h1>
<p>{% trans "We were unable to parse your request." %}</p>
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
&middot; <a id='reload' href='#'>{% trans "Try again" %}</a>
</p>
<i class="fa fa-frown-o fa-fw big-icon"></i>
<div class="error-details">
<h1>{% trans "Bad Request" %}</h1>
<p>{% trans "We were unable to parse your request." %}</p>
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
&middot; <a id='reload' href='#'>{% trans "Try again" %}</a>
</p>
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
</div>
{% endblock %}

View File

@@ -1,23 +1,27 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "Permission denied" %}{% endblock %}
{% block content %}
<i class="fa fa-lock big-icon"></i>
<h1>{% trans "Permission denied" %}</h1>
<p>{% trans "You do not have access to this page." %}</p>
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
&middot; <a id='reload' href='#'>{% trans "Try again" %}</a>
</p>
{% if request.user.is_staff and not staff_session %}
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
<p>
{% csrf_token %}
<button type="submit" class="btn btn-default" id="button-sudo">
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
</button>
</p>
</form>
{% endif %}
<i class="fa fa-fw fa-lock big-icon"></i>
<div class="error-details">
<h1>{% trans "Permission denied" %}</h1>
<p>{% trans "You do not have access to this page." %}</p>
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
&middot; <a id='reload' href='#'>{% trans "Try again" %}</a>
</p>
{% if request.user.is_staff and not staff_session %}
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
<p>
{% csrf_token %}
<button type="submit" class="btn btn-default" id="button-sudo">
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
</button>
</p>
</form>
{% endif %}
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
</div>
{% endblock %}

View File

@@ -1,22 +1,26 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "Not found" %}{% endblock %}
{% block content %}
<i class="fa fa-meh-o big-icon"></i>
<h1>{% trans "Not found" %}</h1>
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
</p>
{% if request.user.is_staff and not staff_session %}
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
<p>
{% csrf_token %}
<button type="submit" class="btn btn-default" id="button-sudo">
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
</button>
</p>
</form>
{% endif %}
<i class="fa fa-meh-o fa-fw big-icon"></i>
<div class="error-details">
<h1>{% trans "Not found" %}</h1>
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
</p>
{% if request.user.is_staff and not staff_session %}
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
<p>
{% csrf_token %}
<button type="submit" class="btn btn-default" id="button-sudo">
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
</button>
</p>
</form>
{% endif %}
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
</div>
{% endblock %}

View File

@@ -1,23 +1,27 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "Internal Server Error" %}{% endblock %}
{% block content %}
<i class="fa fa-bolt big-icon"></i>
<h1>{% trans "Internal Server Error" %}</h1>
<p>{% trans "We had trouble processing your request." %}</p>
<p>{% trans "If this problem persists, please contact us." %}</p>
{% if request.sentry.id %}
<p>
{% blocktrans trimmed %}
If you contact us, please send us the following code:
{% endblocktrans %}
<br>
{{ request.sentry.id }}
<i class="fa fa-bolt big-icon fa-fw"></i>
<div class="error-details">
<h1>{% trans "Internal Server Error" %}</h1>
<p>{% trans "We had trouble processing your request." %}</p>
<p>{% trans "If this problem persists, please contact us." %}</p>
{% if sentry_event_id %}
<p>
{% blocktrans trimmed %}
If you contact us, please send us the following code:
{% endblocktrans %}
<br>
{{ sentry_event_id }}
</p>
{% endif %}
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
&middot; <a id='reload' href='#'>{% trans "Try again" %}</a>
</p>
{% endif %}
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
&middot; <a id='reload' href='#'>{% trans "Try again" %}</a>
</p>
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
</div>
{% endblock %}

View File

@@ -1,24 +1,30 @@
{% extends "error.html" %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "Verification failed" %}{% endblock %}
{% block content %}
<i class="fa fa-frown-o big-icon"></i>
<h1>{% trans "Verification failed" %}</h1>
<p>{% blocktrans trimmed %}
We could not verify that this request really was sent from you. For security reasons, we therefore cannot process it.
{% endblocktrans %}</p>
{% if no_referer %}
<p>{{ no_referer1 }}</p>
<p>{{ no_referer2 }}</p>
{% elif no_cookie %}
<p>{{ no_cookie1 }}</p>
<p>{{ no_cookie2 }}</p>
{% else %}
<i class="fa fa-frown-o big-icon fa-fw"></i>
<div class="error-details">
<h1>{% trans "Verification failed" %}</h1>
<p>{% blocktrans trimmed %}
Please go back to the last page, refresh this page and then try again. If the problem persists, please get in touch with us.
We could not verify that this request really was sent from you. For security reasons, we therefore cannot
process it.
{% endblocktrans %}</p>
{% endif %}
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
</p>
{% if no_referer %}
<p>{{ no_referer1 }}</p>
<p>{{ no_referer2 }}</p>
{% elif no_cookie %}
<p>{{ no_cookie1 }}</p>
<p>{{ no_cookie2 }}</p>
{% else %}
<p>{% blocktrans trimmed %}
Please go back to the last page, refresh this page and then try again. If the problem persists, please
get in touch with us.
{% endblocktrans %}</p>
{% endif %}
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
</p>
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% load compress %}
{% load i18n %}
{% load static %}
{% load staticfiles %}
<!DOCTYPE html>
<html>
<head>
@@ -16,6 +16,6 @@
<div class="container">
{% block content %}{% endblock %}
</div>
<script src="{% static "pretixbase/js/errors.js" %}"></script>
</body>
<script src="{% static "pretixbase/js/errors.js" %}"></script>
</html>

View File

@@ -84,7 +84,7 @@ def markdown_compile(source):
source,
extensions=[
'markdown.extensions.sane_lists',
# 'markdown.extensions.nl2br', # TODO: Enable, but check backwards-compatibility issues e.g. with mails
'markdown.extensions.nl2br'
]
),
tags=ALLOWED_TAGS,

View File

@@ -70,14 +70,12 @@ class BaseTicketOutput:
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
appropriate filters for you.
"""
with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for pos in order.positions.all():
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
continue
if not pos.item.admission and not self.event.settings.ticket_download_nonadm:
continue
for pos in order.positions_with_tickets:
fname, __, content = self.generate(pos)
zipf.writestr('{}-{}{}'.format(
order.code, pos.positionid, os.path.splitext(fname)[1]

View File

@@ -7,6 +7,7 @@ from django.template.loader import get_template
from django.utils.functional import Promise
from django.utils.translation import ugettext as _
from django.views.decorators.csrf import requires_csrf_token
from sentry_sdk import last_event_id
def csrf_failure(request, reason=""):
@@ -65,5 +66,6 @@ def server_error(request):
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
return HttpResponseServerError(template.render({
'request': request
'request': request,
'sentry_event_id': last_event_id(),
}))

View File

@@ -146,6 +146,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
invoice_form_class = BaseInvoiceAddressForm
invoice_name_form_class = BaseInvoiceNameForm
only_user_visible = True
all_optional = False
@cached_property
def _positions_for_questions(self):
@@ -189,12 +190,14 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
return self.invoice_name_form_class(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address, validate_vat_id=False
instance=self.invoice_address, validate_vat_id=False,
all_optional=self.all_optional
)
return self.invoice_form_class(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address, validate_vat_id=False
instance=self.invoice_address, validate_vat_id=False,
all_optional=self.all_optional,
)
def get_context_data(self, **kwargs):

View File

@@ -34,6 +34,7 @@ def contextprocessor(request):
ctx = {
'url_name': url.url_name,
'settings': settings,
'django_settings': settings,
'DEBUG': settings.DEBUG,
}
_html_head = []
@@ -51,6 +52,15 @@ def contextprocessor(request):
ctx['has_domain'] = request.event.organizer.domains.exists()
if not request.event.testmode:
complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
if complain_testmode_orders is None:
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
ctx['complain_testmode_orders'] = complain_testmode_orders
else:
ctx['complain_testmode_orders'] = False
if not request.event.live and ctx['has_domain']:
child_sess = request.session.get('child_session_{}'.format(request.event.pk))
s = SessionStore()

View File

@@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db.models import Q
from django.forms import formset_factory
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import (
pgettext, pgettext_lazy, ugettext_lazy as _,
@@ -159,6 +161,15 @@ class EventWizardBasicsForm(I18nModelForm):
return slug
class EventChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return mark_safe('{}<br /><span class="text-muted">{} · {}</span>'.format(
escape(str(obj)),
obj.get_date_range_display() if not obj.has_subevents else _("Event series"),
obj.slug
))
class EventWizardCopyForm(forms.Form):
@staticmethod
@@ -177,7 +188,7 @@ class EventWizardCopyForm(forms.Form):
kwargs.pop('has_subevents')
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['copy_from_event'] = forms.ModelChoiceField(
self.fields['copy_from_event'] = EventChoiceField(
label=_("Copy configuration from"),
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
widget=forms.RadioSelect,
@@ -397,11 +408,6 @@ class EventSettingsForm(SettingsForm):
required=False,
help_text=_("We'll show this publicly to allow attendees to contact you.")
)
cancel_allow_user = forms.BooleanField(
label=_("Allow users to cancel unpaid orders"),
help_text=_("If checked, users can cancel orders by themselves as long as they are not yet paid."),
required=False
)
def clean(self):
data = super().clean()
@@ -435,6 +441,39 @@ class EventSettingsForm(SettingsForm):
)
class CancelSettingsForm(SettingsForm):
cancel_allow_user = forms.BooleanField(
label=_("Customers can cancel their unpaid orders"),
required=False
)
cancel_allow_user_until = RelativeDateTimeField(
label=_("Do not allow cancellations after"),
required=False
)
cancel_allow_user_paid = forms.BooleanField(
label=_("Customers can cancel their paid orders"),
help_text=_("Paid money will be automatically paid back if the payment method allows it. "
"Otherwise, a manual refund will be created for you to process manually."),
required=False
)
cancel_allow_user_paid_keep = forms.DecimalField(
label=_("Keep a fixed cancellation fee"),
required=False
)
cancel_allow_user_paid_keep_fees = forms.BooleanField(
label=_("Keep payment, shipping and service fees"),
required=False
)
cancel_allow_user_paid_keep_percentage = forms.DecimalField(
label=_("Keep a percentual cancellation fee"),
required=False
)
cancel_allow_user_paid_until = RelativeDateTimeField(
label=_("Do not allow cancellations after"),
required=False
)
class PaymentSettingsForm(SettingsForm):
payment_term_days = forms.IntegerField(
label=_('Payment term in days'),
@@ -478,6 +517,16 @@ class PaymentSettingsForm(SettingsForm):
help_text=_("The tax rule that applies for additional fees you configured for single payment methods. This "
"will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.")
)
payment_explanation = I18nFormField(
widget=I18nTextarea,
widget_kwargs={'attrs': {
'rows': 3,
}},
required=False,
label=_("Guidance text"),
help_text=_("This text will be shown above the payment options. You can explain the choices to the user here, "
"if you want.")
)
def clean(self):
cleaned_data = super().clean()
@@ -567,6 +616,11 @@ class InvoiceSettingsForm(SettingsForm):
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
required=False
)
invoice_address_beneficiary = forms.BooleanField(
label=_("Ask for beneficiary"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
required=False
)
invoice_include_free = forms.BooleanField(
label=_("Show free products on invoices"),
help_text=_("Note that invoices will never be generated for orders that contain only free "
@@ -919,6 +973,13 @@ class MailSettingsForm(SettingsForm):
self.fields['mail_html_renderer'].choices = [
(r.identifier, r.verbose_name) for r in event.get_html_mail_renderers().values()
]
keys = list(event.meta_data.keys())
for k, v in self.fields.items():
if k.startswith('mail_text_'):
v.help_text = str(v.help_text) + ', ' + ', '.join({
'{meta_' + p + '}' for p in keys
})
v.validators[0].limit_value += ['{meta_' + p + '}' for p in keys]
def clean(self):
data = self.cleaned_data

View File

@@ -1,11 +1,14 @@
from datetime import datetime, time
from django import forms
from django.apps import apps
from django.db.models import Exists, F, OuterRef, Q
from django.db.models.functions import Coalesce, ExtractWeekDay
from django.urls import reverse, reverse_lazy
from django.utils.timezone import now
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
Checkin, Event, Invoice, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
@@ -97,14 +100,13 @@ class OrderFilterForm(FilterForm):
label=_('Order status'),
choices=(
('', _('All orders')),
('p', _('Paid')),
('n', _('Pending')),
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
(Order.STATUS_PENDING, _('Pending')),
('o', _('Pending (overdue)')),
('np', _('Pending or paid')),
('e', _('Expired')),
('ne', _('Pending or expired')),
('c', _('Canceled')),
('r', _('Refunded')),
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
(Order.STATUS_EXPIRED, _('Expired')),
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
(Order.STATUS_CANCELED, _('Canceled')),
),
required=False,
)
@@ -173,7 +175,7 @@ class OrderFilterForm(FilterForm):
class EventOrderFilterForm(OrderFilterForm):
orders = {'code': 'code', 'email': 'email', 'total': 'total',
'datetime': 'datetime', 'status': 'status', 'pcnt': 'pcnt'}
'datetime': 'datetime', 'status': 'status'}
item = forms.ModelChoiceField(
label=_('Products'),
@@ -198,18 +200,19 @@ class EventOrderFilterForm(OrderFilterForm):
label=_('Order status'),
choices=(
('', _('All orders')),
('p', _('Paid')),
('n', _('Pending')),
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
(Order.STATUS_PENDING, _('Pending')),
('o', _('Pending (overdue)')),
('np', _('Pending or paid')),
('e', _('Expired')),
('ne', _('Pending or expired')),
('c', _('Canceled')),
('r', _('Refunded')),
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
(Order.STATUS_EXPIRED, _('Expired')),
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
(Order.STATUS_CANCELED, _('Canceled')),
('cp', _('Canceled (or with paid fee)')),
('pa', _('Approval pending')),
('overpaid', _('Overpaid')),
('underpaid', _('Underpaid')),
('pendingpaid', _('Pending (but fully paid)'))
('pendingpaid', _('Pending (but fully paid)')),
('testmode', _('Test mode')),
),
required=False,
)
@@ -243,10 +246,10 @@ class EventOrderFilterForm(OrderFilterForm):
qs = super().filter_qs(qs)
if fdata.get('item'):
qs = qs.filter(positions__item=fdata.get('item'))
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False).distinct()
if fdata.get('subevent'):
qs = qs.filter(positions__subevent=fdata.get('subevent'))
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False).distinct()
if fdata.get('question') and fdata.get('answer') is not None:
q = fdata.get('question')
@@ -274,16 +277,19 @@ class EventOrderFilterForm(OrderFilterForm):
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
if fdata.get('status') == 'overpaid':
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
qs = qs.filter(
Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0))
| Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0))
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
)
elif fdata.get('status') == 'pendingpaid':
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
qs = qs.filter(
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
& Q(require_approval=False)
)
elif fdata.get('status') == 'underpaid':
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
qs = qs.filter(
status=Order.STATUS_PAID,
pending_sum_t__gt=0
@@ -293,13 +299,26 @@ class EventOrderFilterForm(OrderFilterForm):
status=Order.STATUS_PENDING,
require_approval=True
)
elif fdata.get('status') == 'testmode':
qs = qs.filter(
testmode=True
)
elif fdata.get('status') == 'cp':
s = OrderPosition.objects.filter(
order=OuterRef('pk')
)
qs = qs.annotate(
has_pc=Exists(s)
).filter(
Q(status=Order.STATUS_PAID, has_pc=False) | Q(status=Order.STATUS_CANCELED)
)
return qs
class OrderSearchFilterForm(OrderFilterForm):
orders = {'code': 'code', 'email': 'email', 'total': 'total',
'datetime': 'datetime', 'status': 'status', 'pcnt': 'pcnt',
'datetime': 'datetime', 'status': 'status',
'event': 'event'}
organizer = forms.ModelChoiceField(
@@ -355,6 +374,11 @@ class SubEventFilterForm(FilterForm):
),
required=False
)
date = forms.DateField(
label=_('Date'),
required=False,
widget=DatePickerWidget
)
weekday = forms.ChoiceField(
label=_('Weekday'),
choices=(
@@ -378,6 +402,10 @@ class SubEventFilterForm(FilterForm):
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['date'].widget = DatePickerWidget()
def filter_qs(self, qs):
fdata = self.cleaned_data
@@ -407,6 +435,20 @@ class SubEventFilterForm(FilterForm):
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
)
if fdata.get('date'):
date_start = make_aware(datetime.combine(
fdata.get('date'),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
date_end = make_aware(datetime.combine(
fdata.get('date'),
time(hour=23, minute=59, second=59, microsecond=999999)
), get_current_timezone())
qs = qs.filter(
Q(date_to__isnull=True, date_from__gte=date_start, date_from__lte=date_end) |
Q(date_to__isnull=False, date_from__lte=date_end, date_to__gte=date_start)
)
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())

View File

@@ -160,6 +160,7 @@ class ItemCreateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
@@ -239,6 +240,9 @@ class ItemCreateForm(I18nModelForm):
if self.cleaned_data.get('quota_option') == self.EXISTING and self.cleaned_data.get('quota_add_existing') is not None:
quota = self.cleaned_data.get('quota_add_existing')
quota.items.add(self.instance)
quota.log_action('pretix.event.quota.changed', user=self.user, data={
'item_added': self.instance.pk
})
elif self.cleaned_data.get('quota_option') == self.NEW:
quota_name = self.cleaned_data.get('quota_add_new_name')
quota_size = self.cleaned_data.get('quota_add_new_size')
@@ -247,6 +251,11 @@ class ItemCreateForm(I18nModelForm):
event=self.event, name=quota_name, size=quota_size
)
quota.items.add(self.instance)
quota.log_action('pretix.event.quota.added', user=self.user, data={
'name': quota_name,
'size': quota_size,
'items': [self.instance.pk]
})
if self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
@@ -297,6 +306,16 @@ class ItemCreateForm(I18nModelForm):
]
class TicketNullBooleanSelect(forms.NullBooleanSelect):
def __init__(self, attrs=None):
choices = (
('1', _('Choose automatically depending on event settings')),
('2', _('Yes, if ticket generation is enabled in general')),
('3', _('Never')),
)
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
class ItemUpdateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -340,6 +359,7 @@ class ItemUpdateForm(I18nModelForm):
'max_per_order',
'min_per_order',
'checkin_attention',
'generate_tickets',
'original_price'
]
field_classes = {
@@ -349,6 +369,7 @@ class ItemUpdateForm(I18nModelForm):
widgets = {
'available_from': SplitDateTimePickerWidget(),
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
'generate_tickets': TicketNullBooleanSelect()
}

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -75,6 +77,40 @@ class ConfirmPaymentForm(forms.Form):
del self.fields['force']
class CancelForm(ConfirmPaymentForm):
send_email = forms.BooleanField(
required=False,
label=_('Notify user by e-mail'),
initial=True
)
cancellation_fee = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
localize=True,
label=_('Keep a cancellation fee of'),
help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced '
'to a paid cancellation fee. Payment and shipping fees will be canceled as well, so include them '
'in your cancellation fee if you want to keep them. Please always enter a gross value, '
'tax will be calculated automatically.'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
prs = self.instance.payment_refund_sum
if prs > 0:
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
self.fields['cancellation_fee'].initial = Decimal('0.00')
self.fields['cancellation_fee'].max_value = prs
else:
del self.fields['cancellation_fee']
def clean_cancellation_fee(self):
val = self.cleaned_data['cancellation_fee']
if val > self.instance.payment_refund_sum:
raise ValidationError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
return val
class MarkPaidForm(ConfirmPaymentForm):
amount = forms.DecimalField(
required=True,
@@ -86,7 +122,7 @@ class MarkPaidForm(ConfirmPaymentForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['amount'], self.instance.event.currency)
self.fields['amount'].initial = max(0, self.instance.pending_sum)
self.fields['amount'].initial = max(Decimal('0.00'), self.instance.pending_sum)
class ExporterForm(forms.Form):
@@ -141,6 +177,10 @@ class OtherOperationsForm(forms.Form):
'Send an email to the customer notifying that their order has been changed.'
)
)
ignore_quotas = forms.BooleanField(
label=_('Allow to overbook quotas when performing this operation'),
required=False,
)
def __init__(self, *args, **kwargs):
kwargs.pop('order')
@@ -250,6 +290,7 @@ class OrderPositionChangeForm(forms.Form):
('secret', 'Regenerate secret'),
)
)
change_product_keep_price = forms.BooleanField(required=False)
def __init__(self, *args, **kwargs):
instance = kwargs.pop('instance')
@@ -365,8 +406,7 @@ class OrderRefundForm(forms.Form):
required=False,
widget=forms.RadioSelect,
choices=(
('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will '
'no longer work. This can not be reverted.')),
('mark_refunded', _('Cancel the order. All tickets will no longer work. This can not be reverted.')),
('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another '
'payment method.')),
('do_nothing', _('Do nothing and keep the order as it is.')),
@@ -389,7 +429,7 @@ class OrderRefundForm(forms.Form):
self.order = kwargs.pop('order')
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['partial_amount'], self.order.event.currency)
if self.order.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
if self.order.status == Order.STATUS_CANCELED:
del self.fields['action']
def clean_partial_amount(self):

View File

@@ -238,6 +238,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
('calendar', _('Calendar'))
)
)
event_list_availability = forms.BooleanField(
label=_('Show availability in event overviews'),
help_text=_('If checked, the list of events will show if events are sold out. This might '
'make for longer page loading times if you have lots of events and the shown status might be out '
'of date for up to two minutes.'),
required=False
)
organizer_link_back = forms.BooleanField(
label=_('Link back to organizer overview on all event pages'),
required=False
@@ -255,6 +262,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
],
help_text=_('Only respected by modern browsers.')
)
favicon = ExtFileField(
label=_('Favicon'),
ext_whitelist=(".ico", ".png", ".jpg", ".gif", ".jpeg"),
required=False,
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
'We recommend a size of at least 200x200px to accomodate most devices.')
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -57,7 +57,7 @@ class UserEditForm(forms.ModelForm):
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',

View File

@@ -63,7 +63,7 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) removed.').format(
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
posid=data.get('positionid', '?'),
old_item=old_item,
old_price=money_filter(Decimal(data['old_price']), event.currency),
@@ -172,6 +172,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.canceled': _('The order has been canceled.'),
'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'),
'pretix.event.order.placed': _('The order has been created.'),
'pretix.event.order.placed.require_approval': _('The order requires approval before it can continue to be processed.'),
'pretix.event.order.approved': _('The order has been approved.'),
@@ -187,6 +188,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'toggled.'),
'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.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.'),
@@ -211,8 +213,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
'pretix.event.order.refund.requested': _('The customer requested you to issue a refund.'),
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'),
'pretix.control.auth.user.created': _('The user has been created.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
@@ -265,6 +269,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.plugins.disabled': _('A plugin has been disabled.'),
'pretix.event.live.activated': _('The shop has been taken live.'),
'pretix.event.live.deactivated': _('The shop has been taken offline.'),
'pretix.event.testmode.activated': _('The shop has been taken into test mode.'),
'pretix.event.testmode.deactivated': _('The test mode has been disabled.'),
'pretix.event.added': _('The event has been created.'),
'pretix.event.changed': _('The event settings have been changed.'),
'pretix.event.question.option.added': _('An answer option has been added to the question.'),

View File

@@ -88,6 +88,14 @@ def get_event_navigation(request: HttpRequest):
}),
'active': url.url_name == 'event.settings.invoice',
},
{
'label': pgettext_lazy('action', 'Cancellation'),
'url': reverse('control:event.settings.cancel', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.cancel',
},
{
'label': _('Widget'),
'url': reverse('control:event.settings.widget', kwargs={

View File

@@ -201,6 +201,16 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
Additionally, the argument ``order`` and ``request`` are available.
"""
order_position_buttons = EventPluginSignal(
providing_args=["order", "position", "request"]
)
"""
This signal is sent out to display additional buttons for a single position of an order.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
"""
nav_event_settings = EventPluginSignal(
providing_args=['request']
)

View File

@@ -1,10 +1,10 @@
{% load compress %}
{% load i18n %}
{% load static %}
{% load staticfiles %}
<!DOCTYPE html>
<html>
<head>
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
<title>{{ django_settings.PRETIX_INSTANCE_NAME }}</title>
{% compress css %}
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/auth.scss" %}"/>
{% endcompress %}

View File

@@ -64,6 +64,7 @@
<meta name="msapplication-TileColor" content="#3b1c4a">
<meta name="msapplication-config" content="{% url "presale:browserconfig.xml" %}">
<meta name="theme-color" content="#3b1c4a">
<meta name="referrer" content="origin">
{% block custom_header %}{% endblock %}
</head>
@@ -234,6 +235,14 @@
</ul>
</div>
<ul class="nav" id="side-menu">
{% if request.event and request.event.testmode %}
<li class="testmode">
<a href="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}">
<i class="fa fa-warning fa-fw"></i>
{% trans "TEST MODE" %}
</a>
</li>
{% endif %}
{% block nav %}
{% for nav in nav_items %}
<li>
@@ -316,6 +325,20 @@
</div>
{% endfor %}
{% endif %}
{% if complain_testmode_orders %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
Your event contains <strong>test mode orders</strong> even though <strong>test mode has been disabled</strong>.
You should delete those orders to make sure they do not show up in your reports and statistics and block people from
actually buying tickets.
{% endblocktrans %}
<strong>
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.organizer.slug %}?status=testmode">
{% trans "Show all test mode orders" %}
</a>
</strong>
</div>
{% endif %}
{% if warning_update_check_active %}
<div class="alert alert-info">
<a href="{% url "control:global.update" %}">

View File

@@ -21,7 +21,7 @@
<span class="fa fa-download"></span>
{% trans "PDF" %}
</a>
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlistcsv&checkinlistcsv-list={{ checkinlist.pk }}"
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlist&checkinlist-list={{ checkinlist.pk }}"
class="btn btn-default" target="_blank">
<span class="fa fa-download"></span>
{% trans "CSV" %}
@@ -89,6 +89,9 @@
{% if e.order.status == "n" %}
<span class="label label-warning">{% trans "unpaid" %}</span>
{% endif %}
{% if e.order.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>{{ e.item }}{% if e.variation %} {{ e.variation }}{% endif %}</td>
<td>{{ e.order.email }}</td>

View File

@@ -0,0 +1,40 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inside %}
<h1>{% trans "Cancellation settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Cancellation of unpaid or free orders" %}</legend>
{% bootstrap_field form.cancel_allow_user layout="control" %}
{% bootstrap_field form.cancel_allow_user_until layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Cancellation of paid orders" %}</legend>
{% bootstrap_field form.cancel_allow_user_paid layout="control" %}
{% bootstrap_field form.cancel_allow_user_paid_keep layout="control" %}
{% bootstrap_field form.cancel_allow_user_paid_keep_percentage layout="control" %}
{% bootstrap_field form.cancel_allow_user_paid_keep_fees layout="control" %}
{% bootstrap_field form.cancel_allow_user_paid_until layout="control" %}
{% if not gets_notification %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
If a user requests cancels a paid order and the money can not be refunded automatically, e.g.
due to the selected payment method, you will need to take manual action. However, you have
currently turned off notifications for this event.
{% endblocktrans %}
<a href="{% url "control:user.settings.notifications" %}" class="btn btn-default">
{% trans "Change notification settings" %}
</a>
</div>
{% endif %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -23,6 +23,7 @@
{% bootstrap_field form.invoice_name_required layout="control" %}
{% bootstrap_field form.invoice_address_company_required layout="control" %}
{% bootstrap_field form.invoice_address_vatid layout="control" %}
{% bootstrap_field form.invoice_address_beneficiary layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Your invoice details" %}</legend>

View File

@@ -3,49 +3,113 @@
{% load bootstrap3 %}
{% block content %}
<h1>{% trans "Shop status" %}</h1>
{% if request.event.live %}
<p>
{% trans "Your shop is currently live. If you take it down, it will only be visible to you and your team." %}
</p>
<form action="" method="post">
{% csrf_token %}
<input type="hidden" name="live" value="false">
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Go offline" %}
</button>
</div>
</form>
{% else %}
<p>
{% trans "Your ticket shop is currently not live. It is thus only visible to you and your team, not to any visitors." %}
</p>
{% if issues|length > 0 %}
<div class="alert alert-warning">
<div class="panel panel-default">
<div class="panel-heading">
{% trans "Shop visibility" %}
</div>
<div class="panel-body">
{% if request.event.live %}
<p>
{% trans "To publish your ticket shop, you first need to resolve the following issues:" %}
{% trans "Your shop is currently live. If you take it down, it will only be visible to you and your team." %}
</p>
<ul>
{% for issue in issues %}
<li>{{ issue|safe }}</li>
{% endfor %}
</ul>
</div>
{% else %}
<p>
{% trans "If you want to, you can publish your ticket shop now." %}
</p>
<form action="" method="post">
{% csrf_token %}
<input type="hidden" name="live" value="true">
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Go live" %}
<form action="" method="post" class="text-right">
{% csrf_token %}
<input type="hidden" name="live" value="false">
<button type="submit" class="btn btn-lg btn-danger btn-save">
{% trans "Go offline" %}
</button>
</div>
</form>
{% endif %}
{% endif %}
</form>
{% else %}
{% if issues|length > 0 %}
<p>
{% trans "Your ticket shop is currently not live. It is thus only visible to you and your team, not to any visitors." %}
</p>
<div class="alert alert-warning">
<p>
{% trans "To publish your ticket shop, you first need to resolve the following issues:" %}
</p>
<ul>
{% for issue in issues %}
<li>{{ issue|safe }}</li>
{% endfor %}
</ul>
</div>
<div class="test-right">
<button type="submit" class="btn btn-primary btn-lg btn-save" disabled>
{% trans "Go live" %}
</button>
</div>
{% else %}
<p>
{% trans "Your ticket shop is currently not live. It is thus only visible to you and your team, not to any visitors." %}
</p>
<p>
{% trans "If you want to, you can publish your ticket shop now." %}
</p>
<form action="" method="post" class="text-right">
{% csrf_token %}
<input type="hidden" name="live" value="true">
<button type="submit" class="btn btn-primary btn-lg btn-save">
{% trans "Go live" %}
</button>
</form>
{% endif %}
{% endif %}
<div class="clear"></div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
{% trans "Test mode" %}
</div>
<div class="panel-body">
{% if request.event.testmode %}
<form action="" method="post">
{% csrf_token %}
<input type="hidden" name="testmode" value="false">
<p>
{% trans "Your shop is currently in test mode. All orders are not persistant and can be deleted at any point." %}
</p>
<div class="form-inline">
<label class="checkbox">
<input type="checkbox" name="delete" value="yes" />
{% trans "Permanently delete all orders created in test mode" %}
</label>
</div>
<div class="text-right">
<button type="submit" class="btn btn-lg btn-primary btn-save">
{% trans "Disable test mode" %}
</button>
</div>
</form>
{% else %}
<p>
{% trans "Your shop is currently in production mode." %}
</p>
<p>
{% trans "If you want to do some test orders, you can enable test mode for your shop. As long as the shop is in test mode, all orders that are created are marked as test orders and can be deleted again." %}
<strong>
{% trans "Please note that test orders still count into your quotas, actually use vouchers and might perform actual payments. The only difference is that you can delete test orders. Use at your own risk!" %}
</strong>
</p>
<p>
{% trans "Also, test mode only covers the main web shop. Orders created through other sales channels such as the box office or resellers module are still created as production orders." %}
</p>
{% if actual_orders %}
<div class="alert alert-danger">
{% trans "It looks like you already have some real orders in your shop. We do not recommend enabling test mode if your customers already know your shop, as it will confuse them." %}
</div>
{% endif %}
<form action="" method="post" class="text-right">
{% csrf_token %}
<input type="hidden" name="testmode" value="true">
<button type="submit" class="btn btn-danger btn-lg btn-save">
{% trans "Enable test mode" %}
</button>
</form>
{% endif %}
<div class="clear"></div>
</div>
</div>
{% endblock %}

View File

@@ -54,6 +54,7 @@
{% bootstrap_field form.payment_term_expire_automatically layout="control" %}
{% bootstrap_field form.payment_term_accept_late layout="control" %}
{% bootstrap_field form.tax_rate_default layout="control" %}
{% bootstrap_field form.payment_explanation layout="control" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">

View File

@@ -2,6 +2,7 @@
{% load i18n %}
{% load bootstrap3 %}
{% block inside %}
<h1>{% trans "Payment settings" %}</h1>
<form action="" method="post" class="form-horizontal form-plugins">
{% csrf_token %}
<fieldset>

View File

@@ -68,7 +68,6 @@
{% bootstrap_field sform.order_email_asked_twice layout="control" %}
{% bootstrap_field sform.attendee_emails_asked layout="control" %}
{% bootstrap_field sform.attendee_emails_required layout="control" %}
{% bootstrap_field sform.cancel_allow_user layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Waiting list" %}</legend>
@@ -91,6 +90,11 @@
<span class="fa fa-eraser"></span>
{% trans "Delete personal data" %}
</a>
<a href="{% url "control:events.add" %}?clone={{ request.event.pk }}"
class="btn btn-default btn-lg">
<span class="fa fa-copy"></span>
{% trans "Clone event" %}
</a>
</div>
</div>
</form>

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