Compare commits

..

298 Commits

Author SHA1 Message Date
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
Raphael Michel
6b725a9db9 Bump version to 2.3.0 2018-12-06 10:16:55 +01:00
Raphael Michel
989ebbb444 Merge pull request #1115 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-12-05 17:13:35 +01:00
Raphael Michel
0a6efc1e0f Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2890 of 2890 strings)

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

powered by weblate
2018-12-05 16:12:43 +00:00
Raphael Michel
d577a0d286 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
2018-12-05 16:11:04 +00:00
Raphael Michel
6b9b379ce2 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-12-05 17:05:14 +01:00
Raphael Michel
13234b6fd5 Merge pull request #1108 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-12-05 17:04:36 +01:00
Raphael Michel
2fa0067663 Revert "Update po files"
This reverts commit 4e37fa5778.
2018-12-05 17:04:22 +01:00
Raphael Michel
4e37fa5778 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-12-05 17:02:53 +01:00
Alexander Schwartz
bfb74448b1 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (69 of 69 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
arabestia
a255082b07 Translated on translate.pretix.eu (Spanish)
Currently translated at 97.3% (2797 of 2874 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
Matheus Nunes
14df35bd90 Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 10.4% (300 of 2874 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
Alexander Schwartz
bd0ba7baa5 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2874 of 2874 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
Alexander Schwartz
9aa220b95b Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2874 of 2874 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
Alexander Schwartz
3ed4be63fe Translated on translate.pretix.eu (German (informal))
Currently translated at 100,0% (69 of 69 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
Alexander Schwartz
23f4b0b62f Translated on translate.pretix.eu (German)
Currently translated at 100,0% (69 of 69 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
arabestia
4b9acb64da Translated on translate.pretix.eu (Spanish)
Currently translated at 98.6% (68 of 69 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
oocf
ebba0ee0cb Translated on translate.pretix.eu (Spanish)
Currently translated at 97.3% (2796 of 2874 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
arabestia
335ce48d7e Translated on translate.pretix.eu (Spanish)
Currently translated at 97.3% (2796 of 2874 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
Alvaro Enrique Ruano
d9a0c8c523 Translated on translate.pretix.eu (Spanish)
Currently translated at 96.7% (2779 of 2874 strings)

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

powered by weblate
2018-12-05 15:59:59 +00:00
Raphael Michel
a297bd1944 Show that some export parameters are optional 2018-12-05 16:51:08 +01:00
Raphael Michel
953ea26984 Add a custom text to explain usage of vouchers 2018-12-05 16:50:05 +01:00
Raphael Michel
e4f80f7660 Widget: Allow to pre-fill fields in the invoice address 2018-12-05 16:45:05 +01:00
Raphael Michel
128a185957 Improve crashed test handling 2018-12-05 16:16:50 +01:00
Raphael Michel
0bdd14b47a Re-add legacy import 2018-12-05 15:13:50 +01:00
Raphael Michel
3b84b181ad PDF editor: Move questions signal out of ticket provider 2018-12-05 14:45:07 +01:00
Raphael Michel
c9b0626324 Adjust tests to new is_public default 2018-12-04 15:41:57 +01:00
Raphael Michel
dc9a82cade Fix failing tests for previous commits 2018-12-04 15:02:16 +01:00
Raphael Michel
8266733e34 Clarify is_public and turn it on by default 2018-12-04 13:49:25 +01:00
Raphael Michel
246987955b Accept localized input for all fields with localized output 2018-12-02 19:12:21 +01:00
Raphael Michel
b93e7fcb60 Fix #1067 -- Allow to manually create partial payments 2018-12-02 18:32:16 +01:00
Raphael Michel
b1cebdbd99 Fix #582 -- Improve validation of organizer domains 2018-12-02 17:45:48 +01:00
Raphael Michel
d04047abd5 Fix #1105 -- Provide URL in order split log entry 2018-12-02 17:35:31 +01:00
Raphael Michel
efca46945a Fix #953 -- Render markdown in email text previews 2018-12-02 17:11:09 +01:00
Raphael Michel
0f9755e36f Add a warning message to products that are out of timeframe 2018-12-02 16:44:21 +01:00
Raphael Michel
478d8e4116 Add export to .xlsx for lists 2018-11-30 16:10:32 +01:00
Raphael Michel
81693e042c Introduce common base class for CSV exports 2018-11-30 15:56:29 +01:00
Raphael Michel
47b7d7b36c Add separate notification category for orders that require approval 2018-11-30 15:30:35 +01:00
Raphael Michel
ba15c34ce1 Fix #1106 -- Do not send reminders to orders placed in the last two hours 2018-11-30 15:16:12 +01:00
Raphael Michel
94f2ad9325 Highlight items that are unavailable by time 2018-11-29 10:13:43 +01:00
Raphael Michel
d8070ba8a3 Fix missing ticket attachments 2018-11-29 10:01:20 +01:00
Raphael Michel
b1019672b0 Fix file format in real expor 2018-11-27 15:52:57 +01:00
Raphael Michel
631307a4d5 Even with pdftk, use PyPDF to read page size 2018-11-27 09:22:53 +01:00
Raphael Michel
180a26ee1d Fix shredder test 2018-11-26 13:44:40 +01:00
Raphael Michel
7eab1982fe Add support for PDFTK 2018-11-26 13:43:06 +01:00
Raphael Michel
ca59237ebf Use regular asynctasks for order PDF generation 2018-11-26 13:21:25 +01:00
Raphael Michel
cc92210dc2 Retry crashed tests 2018-11-26 12:09:39 +01:00
Raphael Michel
6602afdd6c Use dedicated queue for notifications 2018-11-26 12:05:16 +01:00
Raphael Michel
c7a04bc08a Add cleanup for cached tickets 2018-11-26 12:04:25 +01:00
Raphael Michel
2cc5b7f4e8 Raise error 404 on invalid month 2018-11-26 09:20:48 +01:00
Raphael Michel
453f16af03 Merge pull request #1100 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-11-23 16:22:00 +01:00
Raphael Michel
0f3398ae13 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2874 of 2874 strings)

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

powered by weblate
2018-11-23 15:17:00 +00:00
Raphael Michel
f1b65c8695 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2874 of 2874 strings)

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

powered by weblate
2018-11-23 15:17:00 +00:00
Raphael Michel
2c4c89c8c2 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2874 of 2874 strings)

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

powered by weblate
2018-11-23 15:17:00 +00:00
Raphael Michel
4042b603b7 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2874 of 2874 strings)

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

powered by weblate
2018-11-23 15:17:00 +00:00
Alvaro Enrique Ruano
63b0288383 Translated on translate.pretix.eu (Spanish)
Currently translated at 97.5% (2792 of 2864 strings)

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

powered by weblate
2018-11-23 15:17:00 +00:00
Raphael Michel
7c01fee70b Fix incorrect return statement 2018-11-23 16:16:46 +01:00
Raphael Michel
8127c32ef5 More tolerant i18n deserializing 2018-11-23 16:15:23 +01:00
Raphael Michel
563decdfba Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-11-23 15:37:11 +01:00
Raphael Michel
a205b01d70 Add "paid" note on invoices if there is no open payment 2018-11-23 15:36:39 +01:00
Raphael Michel
b4290384e1 Add sales channels (#1103)
- [x] Data model
- [x] Enforce constraint
- [x] Filter order list
- [x] Set channel on created order
- [x] Products API
- [x] Order API
- [x] Tests
- [x] Filter reports
- [x] Resellers
- [ ] deploy plugins
  - [ ] posbackend
  - [ ] resellers
  - [ ] reports
- [x] Ticketlayouts
- [x] Support in pretixPOS
2018-11-23 15:35:09 +01:00
Raphael Michel
0f76779fb1 Fix involuntarily created invoices 2018-11-21 13:07:28 +01:00
Raphael Michel
f34c528cba Add passphrase to wordlist 2018-11-21 11:51:57 +01:00
Raphael Michel
cf01e04101 PayPal: Improve log display 2018-11-21 11:24:44 +01:00
Martin Gross
a3a63def55 Fix #369 -- Connect with PayPal (#1084)
* Connect with PayPal

* PayPal connect code-review fixes

* PayPal Connect: Global Env selection; Fix for payee-dict

* Fix missing PayPal Connect indicator for Endpoint

* Fix backwards compatibility
2018-11-21 11:14:33 +01:00
Raphael Michel
a3489eea04 PayPal: Properly detect pending sales 2018-11-21 11:14:03 +01:00
Raphael Michel
c6cb98c30a Add documentation on development and enterprise installs 2018-11-21 09:40:44 +01:00
Alvaro Enrique Ruano
332c58c82f Improve docker cache utilization on image construction (#1099)
To provide better use of Docker cache during image build by installing from PIP before creating the layer with the source code (the PIP installations are less probable to change than the rest of the source code, so this layer will not be recreated with all the source code changes)

This was previously discussed in #1094
2018-11-21 09:27:43 +01:00
Raphael Michel
beb0ded6dc Allow to pass user data to the widget (#1095)
- [x] Logic
- [x] Tests
- [x] Docs
- [x] find a way to integrate with tracking
2018-11-20 17:55:37 +01:00
Raphael Michel
b49b2035bd Cancel payments if the pending price of the order changes 2018-11-20 17:41:33 +01:00
Raphael Michel
106c8d373d Fix #1098 -- order search with dash in event slug 2018-11-20 14:33:41 +01:00
Raphael Michel
aee44a3284 Fix marking an overpaid order as paid manually 2018-11-20 10:39:48 +01:00
Raphael Michel
d4c1fcf838 Merge pull request #1087 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-11-20 10:39:26 +01:00
Alvaro Enrique Ruano
832f57c9d7 Translated on translate.pretix.eu (Spanish)
Currently translated at 97.4% (2790 of 2864 strings)

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

powered by weblate
2018-11-20 09:36:17 +00:00
Mikkel Ricky
ac2a9b207d Translated on translate.pretix.eu (Danish)
Currently translated at 57.4% (1645 of 2864 strings)

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

powered by weblate
2018-11-20 09:36:17 +00:00
Maarten van den Berg
f1e5d60a14 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2864 of 2864 strings)

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

powered by weblate
2018-11-20 09:36:17 +00:00
Maarten van den Berg
7b1a1dc754 Translated on translate.pretix.eu (Dutch)
Currently translated at 99.7% (2854 of 2864 strings)

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

powered by weblate
2018-11-20 09:36:17 +00:00
Raphael Michel
c93f804992 Fix #1080 -- Deal with gaps in the invoice database (#1086) 2018-11-20 10:36:13 +01:00
Alvaro Enrique Ruano
1cba4b1d45 Dockerfile improvements for better readability (#1094) 2018-11-20 10:24:53 +01:00
Raphael Michel
22369a5559 Fix CI problems with SQLite (#1076)
* Try to re-enable tests on Py3.5

* Add newer SQLite3
2018-11-19 17:57:50 +01:00
Raphael Michel
a8223ad354 Fix missing return statement 2018-11-19 17:12:48 +01:00
Raphael Michel
c9d3cf7cac Fix exceptions in previous commit 2018-11-19 11:03:54 +01:00
Raphael Michel
bbdbc94f6e Redirect case-insensitive versions of event/organizer slugs 2018-11-19 10:22:40 +01:00
Raphael Michel
5c8d9c4dca Fix incorrect feedback on invite form 2018-11-16 14:13:44 +01:00
Raphael Michel
546ff6e42f Variations: Show a price range on the front page 2018-11-14 15:43:21 +01:00
Raphael Michel
7b7d45ce2e Fix Dutch date format 2018-11-14 15:43:15 +01:00
Raphael Michel
be3ca7c561 Sort cart positions by reasonable values 2018-11-14 15:36:50 +01:00
Raphael Michel
abdb6e2d52 Add "Event date" to PDF editors 2018-11-14 14:39:20 +01:00
Raphael Michel
138ddcdcd7 CSV-export improvements (include voucher, allow semicolon) 2018-11-14 10:02:28 +01:00
Raphael Michel
8ffc6550da Do not allow orders with unavailable items to be completed 2018-11-13 17:55:56 +01:00
Raphael Michel
0734715bab Only warn about bad-contrasat colors 2018-11-13 15:53:18 +01:00
Raphael Michel
7528bfb10b Fix tests for color saving 2018-11-13 14:15:52 +01:00
Raphael Michel
2798fb3468 Avoid setting name_parts to None 2018-11-13 12:59:12 +01:00
Raphael Michel
4e6f4716ec Allow to configure accent colors 2018-11-13 12:56:52 +01:00
Raphael Michel
e523a4e610 Allow to manually generate invoices like in c131ad8c 2018-11-12 13:08:12 +01:00
Raphael Michel
31cec76809 Generate invoice after expired order is extended 2018-11-12 13:08:12 +01:00
Raphael Michel
fdfd9f9275 Fix cart cleanup 2018-11-12 13:08:12 +01:00
oocf
b658c73c19 Removed permissions page from events settings (#1063)
* Removed old configuration for events settings

* Urls file to remove permissions file

* Removing not needed test

* Removing test in permissions
2018-11-12 12:23:40 +01:00
Raphael Michel
ebd3e6f31a Fix TypeError in typeahead 2018-11-12 12:17:49 +01:00
Raphael Michel
ccec114653 Merge pull request #1085 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-11-12 11:41:06 +01:00
Raphael Michel
f0716dc482 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2864 of 2864 strings)

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

powered by weblate
2018-11-12 10:40:47 +00:00
Raphael Michel
513778b2c4 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2864 of 2864 strings)

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

powered by weblate
2018-11-12 10:34:26 +00:00
Raphael Michel
742e403ae2 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-11-12 11:31:32 +01:00
Raphael Michel
09a9d610f8 Make navigation structure more approachable to new users (#1083)
* Move event selector to sidebar

* Unify navigation

* Fix confusing icons
2018-11-12 11:30:36 +01:00
Raphael Michel
b9534f23f5 Delete add-ons explicitly 2018-11-12 11:11:33 +01:00
Raphael Michel
b053f61001 Delete add-on positions explicitly 2018-11-12 11:11:33 +01:00
Felix Rindt
21042f2111 Fix #1071 -- Make payments and invoice address full width panels (#1072)
Solve #1071.

I'm not happy about how the invoice address panel is really wide now.
2018-11-12 10:38:22 +01:00
Raphael Michel
e953474138 Fix a few models.CASCADE 2018-11-11 16:23:37 +01:00
Tobias Kunze
0d438ad07c Remove outline when clicking on checkout progress (#1082) 2018-11-10 14:48:37 +01:00
Raphael Michel
e285b7cff0 Bump version to release 2.3.0.dev0 2018-11-09 16:45:43 +01:00
306 changed files with 95652 additions and 32991 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

@@ -22,6 +22,8 @@ matrix:
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
- python: 3.6
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.6
env: JOB=plugins
- python: 3.6
@@ -36,6 +38,9 @@ addons:
- enchant
- myspell-de-de
- aspell-en
- sqlite3
sources:
- travis-ci/sqlite3
branches:
except:
- /^weblate-.*/

View File

@@ -1,10 +1,26 @@
FROM python:3.6
RUN apt-get update && \
apt-get install -y git libxml2-dev libxslt1-dev python-dev python-virtualenv locales \
libffi-dev build-essential python3-dev zlib1g-dev libssl-dev gettext libpq-dev \
default-libmysqlclient-dev libmemcached-dev libjpeg-dev supervisor nginx sudo \
--no-install-recommends && \
apt-get install -y --no-install-recommends \
build-essential \
default-libmysqlclient-dev \
gettext \
git \
libffi-dev \
libjpeg-dev \
libmemcached-dev \
libpq-dev \
libssl-dev \
libxml2-dev \
libxslt1-dev \
locales \
nginx \
python-dev \
python-virtualenv \
python3-dev \
sudo \
supervisor \
zlib1g-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
dpkg-reconfigure locales && \
@@ -19,6 +35,22 @@ RUN apt-get update && \
ENV LC_ALL=C.UTF-8 \
DJANGO_SETTINGS_MODULE=production_settings
# To copy only the requirements files needed to install from PIP
COPY src/requirements /pretix/src/requirements
COPY src/requirements.txt /pretix/src
RUN pip3 install -U \
pip \
setuptools \
wheel && \
cd /pretix/src && \
pip3 install \
-r requirements.txt \
-r requirements/memcached.txt \
-r requirements/mysql.txt \
-r requirements/redis.txt \
gunicorn && \
rm -rf ~/.cache/pip
COPY deployment/docker/pretix.bash /usr/local/bin/pretix
COPY deployment/docker/supervisord.conf /etc/supervisord.conf
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
@@ -27,11 +59,8 @@ COPY src /pretix/src
RUN chmod +x /usr/local/bin/pretix && \
rm /etc/nginx/sites-enabled/default && \
pip3 install -U pip wheel setuptools && \
cd /pretix/src && \
rm -f pretix.cfg && \
pip3 install -r requirements.txt -r requirements/mysql.txt \
-r requirements/memcached.txt -r requirements/redis.txt gunicorn && \
mkdir -p data && \
chown -R pretixuser:pretixuser /pretix /data data && \
sudo -u pretixuser make production

View File

@@ -295,5 +295,13 @@ various places like order codes, secrets in the ticket QR codes, etc. Example::
; Voucher code needs to be < 255 characters, default is 16
voucher_code=16
External tools
--------------
pretix can make use of some external tools if they are installed. Currently, they are all optional. Example::
[tools]
pdftk=/usr/bin/pdftk
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html

View File

@@ -0,0 +1,37 @@
.. highlight:: none
Installing a development version
================================
If you want to use a feature of pretix that is not yet contained in the last monthly release, you can also
install a development version with pretix.
.. warning:: When in production, we strongly recommend only installing released versions. Development versions might
be broken, incompatible to plugins, or in rare cases incompatible to upgrade later on.
Manual installation
-------------------
You can use ``pip`` to update pretix directly to the development branch. Then, upgrade as usual::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix&subdirectory=src"
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker
Docker installation
-------------------
To use the latest development version with Docker, first pull it from Docker Hub::
$ docker pull pretix/standalone:latest
Then change your ``/etc/systemd/system/pretix.service`` file to use the ``:latest`` tag instead of ``:stable`` as well
and upgrade as usual::
$ systemctl restart pretix.service
$ docker exec -it pretix.service pretix upgrade

View File

@@ -0,0 +1,84 @@
.. highlight:: none
Installing pretix Enterprise plugins
====================================
If you want to use a feature of pretix that is part of our commercial offering pretix Enterprise, you need to follow
some extra steps. Installation works similar to normal pretix plugins, but involves a few extra steps.
Buying the license
------------------
To obtain a license, please get in touch at sales@pretix.eu. Please let us know how many tickets you roughly intend
to sell per year and how many servers you want to use the plugin on. We recommend having a look at our `price list`_
first.
Manual installation
-------------------
First, generate an SSH key for the system user that you install pretix as. In our tutorial, that would be the user
``pretix``. Choose an empty passphrase::
# su pretix
$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/var/pretix/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /var/pretix/.ssh/id_rsa.
Your public key has been saved in /var/pretix/.ssh/id_rsa.pub.
Next, send the content of the *public* key to your sales representative at pretix::
$ cat /var/pretix/.ssh/id_rsa.pub
ssh-rsa AAAAB3N...744HZawHlD pretix@foo
After we configured your key in our system, you can install the plugin directly using ``pip`` from the URL we told
you, for example::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack"
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
# systemctl restart pretix-web pretix-worker
Docker installation
-------------------
To install a plugin, you need to build your own docker image. To do so, create a new directory to work in. As a first
step, generate a new SSH key in that directory to use for authentication with us::
$ cd /home/me/mypretixdocker
$ ssh-keygen -N "" -f id_pretix_enterprise
Next, send the content of the *public* key to your sales representative at pretix::
$ cat id_pretix_enterprise.pub
ssh-rsa AAAAB3N...744HZawHlD pretix@foo
After we configured your key in our system, you can add a ``Dockerfile`` in your directory that includes the newly
generated key and installs the plugin from the URL we told you::
FROM pretix/standalone:stable
USER root
COPY id_pretix_enterprise /root/.ssh/id_rsa
COPY id_pretix_enterprise.pub /root/.ssh/id_rsa.pub
RUN chmod -R 0600 /root/.ssh && \
mkdir -p /etc/ssh && \
ssh-keyscan -t rsa -p 10022 code.rami.io >> /root/.ssh/known_hosts && \
echo StrictHostKeyChecking=no >> /root/.ssh/config && \
pip3 install -Ue "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack" && \
cd /pretix/src && \
sudo -u pretixuser make production
USER pretixuser
Then, build the image for docker::
$ docker build -t mypretix
You can now use that image ``mypretix`` instead of ``pretix/standalone:stable`` in your ``/etc/systemd/system/pretix.service``
service file. Be sure to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an
update to a new version of pretix.
.. _price list: https://pretix.eu/about/en/pricing

View File

@@ -10,3 +10,5 @@ for your needs.
general
docker_smallscale
manual_smallscale
dev_version
enterprise

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

View File

@@ -37,6 +37,8 @@ admission boolean ``True`` for it
position integer An integer, used for sorting
picture string A product picture to be displayed in the shop
(read-only).
sales_channels list of strings Sales channels this product is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this item can be bought
(or ``null``).
available_until datetime The last date time at which this item can be bought
@@ -62,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,
@@ -105,6 +113,14 @@ addons list of objects Definition of a
The field ``require_approval`` has been added.
.. versionchanged:: 2.3
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
@@ -147,6 +163,7 @@ Endpoints
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"sales_channels": ["web"],
"default_price": "23.00",
"original_price": null,
"category": null,
@@ -167,6 +184,7 @@ Endpoints
"max_per_order": null,
"checkin_attention": false,
"has_variations": false,
"generate_tickets": null,
"require_approval": false,
"variations": [
{
@@ -232,6 +250,7 @@ Endpoints
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"sales_channels": ["web"],
"default_price": "23.00",
"original_price": null,
"category": null,
@@ -248,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,
@@ -298,6 +318,7 @@ Endpoints
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"sales_channels": ["web"],
"default_price": "23.00",
"original_price": null,
"category": null,
@@ -314,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,
@@ -351,6 +373,7 @@ Endpoints
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"sales_channels": ["web"],
"default_price": "23.00",
"original_price": null,
"category": null,
@@ -369,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,
@@ -436,6 +460,7 @@ Endpoints
"id": 1,
"name": {"en": "Ticket"},
"internal_name": "",
"sales_channels": ["web"],
"default_price": "25.00",
"original_price": null,
"category": null,
@@ -451,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,10 +26,11 @@ status string Order status, o
* ``p`` paid
* ``e`` expired
* ``c`` canceled
* ``r`` refunded
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
sales_channel string Channel this sale was created through, such as
``"web"``.
datetime datetime Time of order creation
expires datetime The order will expire, if it is still pending by this time
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
@@ -56,9 +57,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
@@ -121,6 +122,15 @@ last_modified datetime Last modificati
nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval``
attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints.
.. versionchanged:: 2.3
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.
.. _order-position-resource:
Order position resource
@@ -265,6 +275,7 @@ List of all orders
"secret": "k24fiuwvu8kxz3y1",
"email": "tester@example.org",
"locale": "en",
"sales_channel": "web",
"datetime": "2017-12-01T10:00:00Z",
"expires": "2017-12-10T10:00:00Z",
"last_modified": "2017-12-01T10:00:00Z",
@@ -401,6 +412,7 @@ Fetching individual orders
"secret": "k24fiuwvu8kxz3y1",
"email": "tester@example.org",
"locale": "en",
"sales_channel": "web",
"datetime": "2017-12-01T10:00:00Z",
"expires": "2017-12-10T10:00:00Z",
"last_modified": "2017-12-01T10:00:00Z",
@@ -561,6 +573,8 @@ Creating orders
* does not validate if products are only to be sold in a specific time frame
* does not validate if products are only to be sold on other sales channels
* does not validate if the event's ticket sales are already over or haven't started
* does not validate the number of items per order or the number of times an item can be included in an order
@@ -597,6 +611,7 @@ Creating orders
creation.
* ``email``
* ``locale``
* ``sales_channel``
* ``payment_provider`` The identifier of the payment provider set for this order. This needs to be an existing
payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all
orders you create as paid.
@@ -661,6 +676,7 @@ Creating orders
{
"email": "dummy@example.org",
"locale": "en",
"sales_channel": "web",
"fees": [
{
"fee_type": "payment",
@@ -764,7 +780,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**:
@@ -776,7 +795,8 @@ Order state operations
Content-Type: text/json
{
"send_email": true
"send_email": true,
"cancellation_fee": null
}
**Example response**:
@@ -837,44 +857,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.
@@ -1054,6 +1036,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.
@@ -1489,7 +1473,7 @@ Order payment endpoints
{
"amount": "23.00",
"mark_refunded": false
"mark_canceled": false
}
@@ -1636,7 +1620,7 @@ Order refund endpoints
"payment": 1,
"execution_date": null,
"provider": "manual",
"mark_refunded": false
"mark_canceled": false
}
**Example response**:
@@ -1706,7 +1690,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**:
@@ -1717,7 +1701,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

@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data
item_copy_data, register_sales_channels
Order events
""""""""""""
@@ -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

@@ -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

@@ -20,6 +20,7 @@ default boolean ``true`` if thi
layout object Layout specification for libpretixprint
background URL Background PDF file
item_assignments list of objects Products this layout is assigned to
├ sales_channel string Sales channel (defaults to ``web``).
└ item integer Item ID
===================================== ========================== =======================================================
@@ -27,6 +28,10 @@ item_assignments list of objects Products this l
This resource has been added.
.. versionchanged:: 2.3
The ``item_assignments.sales_channel`` field has been added.
Endpoints
---------

View File

@@ -1,10 +1,12 @@
addon
addons
Analytics
anonymize
api
auditability
auth
autobuild
availabilities
backend
backends
banktransfer
@@ -65,6 +67,7 @@ ons
optimizations
overpayment
param
passphrase
percental
positionid
pre

View File

@@ -149,8 +149,101 @@ Just as the widget, the button supports the optional attributes ``voucher`` and
You can style the button using the ``pretix-button`` CSS class.
.. versionchanged:: 1.13
Dynamically loading the widget
------------------------------
The pretix Button has been added in version 1.13.
If you need to control the way or timing the widget loads, for example because you want to modify user data (see
below) dynamically via JavaScript, you can register a listener that we will call before creating the widget::
<script type="text/javascript">
window.pretixWidgetCallback = function () {
// Will be run before we create the widget.
}
</script>
If you want, you can suppress us loading the widget and/or modify the user data passed to the widget::
<script type="text/javascript">
window.pretixWidgetCallback = function () {
window.PretixWidget.build_widgets = false;
window.PretixWidget.widget_data["email"] = "test@example.org";
}
</script>
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
Passing user data to the widget
-------------------------------
If you display the widget in a restricted area of your website and you want to pre-fill fields in the checkout process
with known user data to save your users some typing and increase conversions, you can pass additional data attributes
with that information::
<pretix-widget event="https://pretix.eu/demo/democon/"
data-attendee-name-given-name="John"
data-attendee-name-family-name="Doe"
data-invoice-address-name-given-name="John"
data-invoice-address-name-family-name="Doe"
data-email="test@example.org"
data-question-L9G8NG9M="Foobar">
</pretix-widget>
This works for the pretix Button as well. Currently, the following attributes are understood by pretix itself:
* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
* ``data-question-IDENTIFIER`` will pre-fill the answer for the question with the given identifier. You can view and set
identifiers in the *Questions* section of the backend.
* Depending on the person name scheme configured in your event settings, you can pass one or more of
``data-attendee-name-full-name``, ``data-attendee-name-given-name``, ``data-attendee-name-family-name``,
``data-attendee-name-middle-name``, ``data-attendee-name-title``, ``data-attendee-name-calling-name``,
``data-attendee-name-latin-transcription``. If you don't know or don't care, you can also just pass a string as
``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
* ``data-invoice-address-FIELD`` will pre-fill the corresponding field of the invoice address. Possible values for
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city`` and ``country``, as well as fields specified by the
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
country code.
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
Hosted or pretix Enterprise are active, you can pass the following fields:
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
made through this widget will be counted towards this campaign.
* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
require you to dynamically load the widget, like this::
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXXX-1', 'auto');
ga('send', 'pageview');
window.pretixWidgetCallback = function () {
window.PretixWidget.build_widgets = false;
window.addEventListener('load', function() { // Wait for GA to be loaded
if(window.ga && ga.create) {
ga(function(tracker) {
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
window.PretixWidget.buildWidgets()
});
} else { // Tracking is probably blocked
window.PretixWidget.buildWidgets()
}
});
};
</script>
.. versionchanged:: 2.3
Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work
fully if you configured a redis server.
.. _Let's Encrypt: https://letsencrypt.org/

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.2.0"
__version__ = "2.4.0"

View File

@@ -95,7 +95,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)
}

View File

@@ -15,13 +15,20 @@ class I18nField(Field):
super().__init__(**kwargs)
def to_representation(self, value):
if value is None or value.data is None:
if hasattr(value, 'data'):
if isinstance(value.data, dict):
return value.data
elif value.data is None:
return None
else:
return {
settings.LANGUAGE_CODE: str(value.data)
}
elif value is None:
return None
if isinstance(value.data, dict):
return value.data
else:
return {
settings.LANGUAGE_CODE: str(value.data)
settings.LANGUAGE_CODE: str(value)
}
def to_internal_value(self, data):

View File

@@ -74,12 +74,12 @@ class ItemSerializer(I18nAwareModelSerializer):
class Meta:
model = Item
fields = ('id', 'category', 'name', 'internal_name', 'active', 'description',
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
'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

@@ -11,6 +11,7 @@ from rest_framework.relations import SlugRelatedField
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.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
Question, QuestionAnswer,
@@ -113,9 +114,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']
@@ -232,7 +231,7 @@ class OrderSerializer(I18nAwareModelSerializer):
model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval')
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -323,7 +322,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.'
)
@@ -412,7 +411,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
class Meta:
model = Order
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment',
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
def validate_payment_provider(self, pp):
@@ -420,6 +419,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('The given payment provider is not known.')
return pp
def validate_sales_channel(self, channel):
if channel not in get_all_sales_channels():
raise ValidationError('Unknown sales channel.')
return channel
def validate_code(self, code):
if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists():
raise ValidationError(
@@ -551,7 +555,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
@@ -258,9 +258,17 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
except CheckInError as e:
return Response({
'status': 'error',
'reason': e.code
'reason': e.code,
}, status=400)
else:
return Response({
'status': 'ok',
}, 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
@@ -25,8 +26,8 @@ from pretix.api.serializers.order import (
OrderRefundSerializer, OrderSerializer,
)
from pretix.base.models import (
Device, Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
TeamAPIToken,
CachedCombinedTicket, CachedTicket, Device, Invoice, Order, OrderPayment,
OrderPosition, OrderRefund, Quota, TeamAPIToken,
)
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
@@ -38,9 +39,7 @@ from pretix.base.services.orders import (
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded,
)
from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position,
)
from pretix.base.services.tickets import generate
from pretix.base.signals import order_placed, register_ticket_outputs
@@ -130,9 +129,11 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
if order.status != Order.STATUS_PAID:
raise PermissionDenied("Downloads are not available for unpaid orders.")
ct = get_cachedticket_for_order(order, provider.identifier)
if not ct.file:
ct = CachedCombinedTicket.objects.filter(
order=order, provider=provider.identifier, file__isnull=False
).last()
if not ct or not ct.file:
generate.apply_async(args=('order', order.pk, provider.identifier))
raise RetryException()
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
@@ -186,6 +187,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 +201,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'])
@@ -442,14 +456,14 @@ 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 = get_cachedticket_for_position(pos, provider.identifier)
if not ct.file:
ct = CachedTicket.objects.filter(
order_position=pos, provider=provider.identifier, file__isnull=False
).last()
if not ct or not ct.file:
generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
raise RetryException()
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
@@ -513,7 +527,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)
@@ -622,10 +639,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(),
@@ -651,7 +672,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():
@@ -768,7 +792,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

@@ -102,6 +102,7 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
d['checkin_list'] = logentry.parsed_data.get('list')
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
return d
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
@@ -111,6 +112,10 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.placed',
_('New order placed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.placed.required_approval',
_('New order requires approval'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.paid',
_('Order marked as paid'),
@@ -139,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

@@ -0,0 +1,66 @@
import logging
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from pretix.base.signals import register_sales_channels
logger = logging.getLogger(__name__)
_ALL_CHANNELS = None
class SalesChannel:
def __repr__(self):
return '<SalesChannel: {}>'.format(self.identifier)
@property
def identifier(self) -> str:
"""
The internal identifier of this sales channel.
"""
raise NotImplementedError() # NOQA
@property
def verbose_name(self) -> str:
"""
A human-readable name of this sales channel.
"""
raise NotImplementedError() # NOQA
@property
def icon(self) -> str:
"""
The name of a Font Awesome icon to represent this channel
"""
return "circle"
def get_all_sales_channels():
global _ALL_CHANNELS
if _ALL_CHANNELS:
return _ALL_CHANNELS
types = OrderedDict()
for recv, ret in register_sales_channels.send(None):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.identifier] = r
else:
types[ret.identifier] = ret
_ALL_CHANNELS = types
return types
class WebshopSalesChannel(SalesChannel):
identifier = "web"
verbose_name = _('Online shop')
icon = "globe"
@receiver(register_sales_channels, dispatch_uid="base_register_default_sales_channels")
def base_sales_channels(sender, **kwargs):
return (
WebshopSalesChannel(),
)

View File

@@ -1,5 +1,14 @@
import io
import tempfile
from collections import OrderedDict
from typing import Tuple
from defusedcsv import csv
from django import forms
from django.utils.translation import ugettext, ugettext_lazy as _
from openpyxl import Workbook
from openpyxl.cell.cell import KNOWN_TYPES
class BaseExporter:
"""
@@ -55,7 +64,7 @@ class BaseExporter:
"""
return {}
def render(self, form_data: dict) -> Tuple[str, str, str]:
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
"""
Render the exported file and return a tuple consisting of a filename, a file type
and file content.
@@ -69,3 +78,138 @@ class BaseExporter:
tasks.
"""
raise NotImplementedError() # NOQA
class ListExporter(BaseExporter):
@property
def export_form_fields(self) -> dict:
ff = OrderedDict(
[
('_format',
forms.ChoiceField(
label=_('Export format'),
choices=(
('xlsx', _('Excel (.xlsx)')),
('default', _('CSV (with commas)')),
('excel', _('CSV (Excel-style)')),
('semicolon', _('CSV (with semicolons)')),
),
)),
]
)
ff.update(self.additional_form_fields)
return ff
@property
def additional_form_fields(self) -> dict:
return {}
def iterate_list(self, form_data):
raise NotImplementedError() # noqa
def get_filename(self):
return 'export.csv'
def _render_csv(self, form_data, **kwargs):
output = io.StringIO()
writer = csv.writer(output, **kwargs)
for line in self.iterate_list(form_data):
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()
try:
ws.title = str(self.verbose_name)
except:
pass
for i, line in enumerate(self.iterate_list(form_data)):
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 form_data.get('_format') == 'default':
return self._render_csv(form_data, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
elif form_data.get('_format') == 'csv-excel':
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 == 'csv-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

@@ -1,29 +1,37 @@
import io
from collections import OrderedDict
from decimal import Decimal
import pytz
from defusedcsv import csv
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 BaseExporter
from ..exporter import ListExporter, MultiSheetListExporter
from ..signals import register_data_exporters
class OrderListExporter(BaseExporter):
identifier = 'orderlistcsv'
verbose_name = ugettext_lazy('List of orders (CSV)')
class OrderListExporter(MultiSheetListExporter):
identifier = 'orderlist'
verbose_name = ugettext_lazy('Order data')
@property
def export_form_fields(self):
def sheets(self):
return (
('orders', _('Orders')),
('positions', _('Order positions')),
('fees', _('Order fees')),
)
@property
def additional_form_fields(self):
return OrderedDict(
[
('paid_only',
@@ -51,10 +59,16 @@ class OrderListExporter(BaseExporter):
tax_rates = sorted(tax_rates)
return tax_rates
def render(self, form_data: dict):
output = io.StringIO()
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)
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
p_date = OrderPayment.objects.filter(
order=OuterRef('pk'),
@@ -95,7 +109,7 @@ class OrderListExporter(BaseExporter):
headers.append(_('Invoice numbers'))
writer.writerow(headers)
yield headers
full_fee_sum_cache = {
o['order__id']: o['grosssum'] for o in
@@ -162,17 +176,193 @@ class OrderListExporter(BaseExporter):
]
row.append(', '.join([i.number for i in order.invoices.all()]))
writer.writerow(row)
yield row
return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
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(BaseExporter):
identifier = 'paymentlistcsv'
verbose_name = ugettext_lazy('List of payments and refunds (CSV)')
class PaymentListExporter(ListExporter):
identifier = 'paymentlist'
verbose_name = ugettext_lazy('Order payments and refunds')
@property
def export_form_fields(self):
def additional_form_fields(self):
return OrderedDict(
[
('successful_only',
@@ -184,10 +374,8 @@ class PaymentListExporter(BaseExporter):
]
)
def render(self, form_data: dict):
output = io.StringIO()
def iterate_list(self, form_data):
tz = pytz.timezone(self.event.settings.timezone)
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
provider_names = {
k: v.verbose_name
@@ -213,9 +401,9 @@ class PaymentListExporter(BaseExporter):
headers = [
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Amount'), _('Payment method')
_('Status code'), _('Amount'), _('Payment method')
]
writer.writerow(headers)
yield headers
for obj in objs:
if isinstance(obj, OrderPayment) and obj.payment_date:
@@ -230,27 +418,26 @@ class PaymentListExporter(BaseExporter):
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)
]
writer.writerow(row)
yield row
return '{}_payments.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
def get_filename(self):
return '{}_payments'.format(self.event.slug)
class QuotaListExporter(BaseExporter):
identifier = 'quotalistcsv'
verbose_name = ugettext_lazy('Quota availabilities (CSV)')
def render(self, form_data: dict):
output = io.StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
class QuotaListExporter(ListExporter):
identifier = 'quotalist'
verbose_name = ugettext_lazy('Quota availabilities')
def iterate_list(self, form_data):
headers = [
_('Quota name'), _('Total quota'), _('Paid orders'), _('Pending orders'), _('Blocking vouchers'),
_('Current user\'s carts'), _('Waiting list'), _('Current availability')
]
writer.writerow(headers)
yield headers
for quota in self.event.quotas.all():
avail = quota.availability()
@@ -264,9 +451,178 @@ class QuotaListExporter(BaseExporter):
quota.count_waiting_list_pending(),
_('Infinite') if avail[1] is None else avail[1]
]
writer.writerow(row)
yield row
return '{}_quotas.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
def get_filename(self):
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'),
_('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(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")
@@ -282,3 +638,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

@@ -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

@@ -4,6 +4,7 @@ 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
@@ -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,71 @@ 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)
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 +380,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 +421,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 +438,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 +507,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

@@ -189,6 +189,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,29 @@
# Generated by Django 2.1.1 on 2018-11-21 12:24
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', '0102_auto_20181017_0024'),
]
operations = [
migrations.AddField(
model_name='item',
name='sales_channels',
field=pretix.base.models.fields.MultiStringField(default=['web'], verbose_name='Sales channels'),
preserve_default=False,
),
migrations.AddField(
model_name='order',
name='sales_channel',
field=models.CharField(default='web', max_length=190),
preserve_default=False,
),
]

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

@@ -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,10 @@ 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):
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):
@@ -227,10 +301,9 @@ class Event(EventMixin, LoggedModel):
verbose_name=_("Event end time"))
date_admission = models.DateTimeField(null=True, blank=True,
verbose_name=_("Admission time"))
is_public = models.BooleanField(default=False,
verbose_name=_("Visible in public lists"),
help_text=_("If selected, this event may show up on the ticket system's start page "
"or an organization profile."))
is_public = models.BooleanField(default=True,
verbose_name=_("Show in lists"),
help_text=_("If selected, this event will show up publicly on the list of events for your organizer account."))
presale_end = models.DateTimeField(
null=True, blank=True,
verbose_name=_("End of presale"),
@@ -282,10 +355,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):
@@ -469,6 +543,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,
@@ -573,8 +648,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'),
@@ -582,7 +659,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())
@@ -675,6 +752,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()
@@ -683,7 +761,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

@@ -0,0 +1,94 @@
from django.core import exceptions
from django.db.models import TextField, lookups as builtin_lookups
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
DELIMITER = "\x1F"
class MultiStringField(TextField):
default_error_messages = {
'delimiter_found': _('No value can contain the delimiter character.')
}
def __init__(self, verbose_name=None, name=None, **kwargs):
super().__init__(verbose_name, name, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
return name, path, args, kwargs
def to_python(self, value):
if isinstance(value, (list, tuple)):
return value
elif value:
return [v for v in value.split(DELIMITER) if v]
else:
return []
def get_prep_value(self, value):
if isinstance(value, (list, tuple)):
return DELIMITER + DELIMITER.join(value) + DELIMITER
elif value is None:
return ""
raise TypeError("Invalid data type passed.")
def get_prep_lookup(self, lookup_type, value): # NOQA
raise TypeError('Lookups on multi strings are currently not supported.')
def from_db_value(self, value, expression, connection, context):
if value:
return [v for v in value.split(DELIMITER) if v]
else:
return []
def validate(self, value, model_instance):
super().validate(value, model_instance)
for l in value:
if DELIMITER in l:
raise exceptions.ValidationError(
self.error_messages['delimiter_found'],
code='delimiter_found',
)
def get_lookup(self, lookup_name):
if lookup_name == 'contains':
return MultiStringContains
elif lookup_name == 'icontains':
return MultiStringIContains
raise NotImplementedError(
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
)
class MultiStringContains(builtin_lookups.Contains):
def process_rhs(self, qn, connection):
sql, params = super().process_rhs(qn, connection)
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
return sql, params
class MultiStringIContains(builtin_lookups.IContains):
def process_rhs(self, qn, connection):
sql, params = super().process_rhs(qn, connection)
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
return sql, params
class MultiStringSerializer(serializers.Field):
def __init__(self, **kwargs):
self.allow_blank = kwargs.pop('allow_blank', False)
super().__init__(**kwargs)
def to_representation(self, value):
return value
def to_internal_value(self, data):
if isinstance(data, list):
return data
else:
raise ValidationError('Invalid data type.')
serializers.ModelSerializer.serializer_field_mapping[MultiStringField] = MultiStringSerializer

View File

@@ -2,6 +2,8 @@ import string
from decimal import Decimal
from django.db import DatabaseError, models, transaction
from django.db.models import Max
from django.db.models.functions import Cast
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -89,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)
@@ -124,8 +127,12 @@ class Invoice(models.Model):
numeric_invoices = Invoice.objects.filter(
event__organizer=self.event.organizer,
prefix=self.prefix,
).exclude(invoice_no__contains='-')
return self._to_numeric_invoice_number(numeric_invoices.count() + 1)
).exclude(invoice_no__contains='-').annotate(
numeric_number=Cast('invoice_no', models.IntegerField())
).aggregate(
max=Max('numeric_number')
)['max'] or 0
return self._to_numeric_invoice_number(numeric_invoices + 1)
def _get_invoice_number_from_order(self):
return '{order}-{count}'.format(
@@ -183,7 +190,10 @@ class Invoice(models.Model):
class Meta:
unique_together = ('organizer', 'prefix', 'invoice_no')
ordering = ('invoice_no',)
ordering = ('date', 'invoice_no',)
def __repr__(self):
return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)
class InvoiceLine(models.Model):

View File

@@ -17,6 +17,7 @@ from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
from pretix.base.models.tax import TaxedPrice
@@ -152,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.
@@ -195,8 +220,12 @@ class Item(LoggedModel):
:type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
:type require_approval: bool
:param sales_channels: Sales channels this item is available on.
:type sales_channels: bool
"""
objects = ItemQuerySet.as_manager()
event = models.ForeignKey(
Event,
on_delete=models.PROTECT,
@@ -258,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
)
@@ -298,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'),
@@ -329,6 +361,10 @@ class Item(LoggedModel):
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=['web']
)
# !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/forms/item.py if applicable.
@@ -357,17 +393,21 @@ class Item(LoggedModel):
rate=Decimal('0.00'), name='')
return self.tax_rule.tax(price, base_price_is=base_price_is)
def is_available_by_time(self, now_dt: datetime=None) -> bool:
now_dt = now_dt or now()
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
return False
return True
def is_available(self, now_dt: datetime=None) -> bool:
"""
Returns whether this item is available according to its ``active`` flag
and its ``available_from`` and ``available_until`` fields
"""
now_dt = now_dt or now()
if not self.active:
return False
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
if not self.active or not self.is_available_by_time(now_dt):
return False
return True
@@ -405,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):
@@ -750,7 +790,7 @@ class Question(LoggedModel):
@staticmethod
def _clean_identifier(event, code, instance=None):
qs = Question.objects.filter(event=event, identifier=code)
qs = Question.objects.filter(event=event, identifier__iexact=code)
if instance:
qs = qs.exclude(pk=instance.pk)
if qs.exists():
@@ -919,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
@@ -1001,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.
"""
@@ -1016,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
@@ -1053,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,7 +71,6 @@ class Order(LockModel, LoggedModel):
* ``STATUS_PAID``
* ``STATUS_EXPIRED``
* ``STATUS_CANCELED``
* ``STATUS_REFUNDED``
:param event: The event this order belongs to
:type event: Event
@@ -94,19 +94,20 @@ class Order(LockModel, LoggedModel):
:type require_approval: bool
:param meta_info: Additional meta information on the order, JSON-encoded.
:type meta_info: str
:param sales_channel: Identifier of the sales channel this order was created through.
:type sales_channel: str
"""
STATUS_PENDING = "n"
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(
@@ -136,7 +137,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")
@@ -174,6 +175,7 @@ class Order(LockModel, LoggedModel):
require_approval = models.BooleanField(
default=False
)
sales_channel = models.CharField(max_length=190, default="web")
class Meta:
verbose_name = _("Order")
@@ -183,6 +185,28 @@ class Order(LockModel, LoggedModel):
def __str__(self):
return self.full_code
@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:
@@ -204,8 +228,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')
@@ -216,7 +240,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')
@@ -234,38 +258,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
@@ -333,10 +366,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({
@@ -389,15 +524,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 (
@@ -555,10 +681,18 @@ class Order(LockModel, LoggedModel):
'subject': subject,
'message': email_content,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else []
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
}
)
@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)
@@ -715,7 +849,7 @@ class AbstractPosition(models.Model):
subevent = models.ForeignKey(
SubEvent,
null=True, blank=True,
on_delete=models.CASCADE,
on_delete=models.PROTECT,
verbose_name=pgettext_lazy("subevent", "Date"),
)
item = models.ForeignKey(
@@ -748,10 +882,10 @@ class AbstractPosition(models.Model):
help_text=_("Empty, if this product is not an admission ticket")
)
voucher = models.ForeignKey(
'Voucher', null=True, blank=True, on_delete=models.CASCADE
'Voucher', null=True, blank=True, on_delete=models.PROTECT
)
addon_to = models.ForeignKey(
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
'self', null=True, blank=True, on_delete=models.PROTECT, related_name='addons'
)
meta_info = models.TextField(
verbose_name=_("Meta information"),
@@ -1250,11 +1384,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
@@ -1271,16 +1413,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")),
)
@@ -1292,7 +1438,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(
@@ -1313,6 +1459,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):
@@ -1367,6 +1517,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
@@ -1379,6 +1532,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
"""
@@ -1386,7 +1541,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(
@@ -1408,6 +1563,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")
@@ -1418,6 +1577,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
@@ -1449,6 +1617,7 @@ class OrderPosition(AbstractPosition):
# Delete afterwards. Deleting in between might cause deletion of things related to add-ons
# due to the deletion cascade.
for cartpos in cp:
cartpos.addons.all().delete()
cartpos.delete()
return ops
@@ -1487,7 +1656,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:
@@ -1503,7 +1672,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
@@ -1591,6 +1760,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:
@@ -1600,6 +1773,7 @@ class InvoiceAddress(models.Model):
self.name_cached = self.name
else:
self.name_cached = ""
self.name_parts = {}
super().save(**kwargs)
@property

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

@@ -193,6 +193,12 @@ def register_default_notification_types(sender, **kwargs):
_('New order placed'),
_('A new order has been placed: {order.code}'),
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.placed.require_approval',
_('New order requires approval'),
_('A new order has been placed that requires approval: {order.code}'),
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.paid',
@@ -237,9 +243,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

@@ -1,13 +1,20 @@
import copy
import logging
import os
import re
import subprocess
import tempfile
import uuid
from collections import OrderedDict
from functools import partial
from io import BytesIO
import bleach
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
@@ -25,7 +32,7 @@ from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from pretix.base.invoice import ThumbnailingImageReader
from pretix.base.models import Order, OrderPosition
from pretix.base.models import Order, OrderPosition, QuestionAnswer
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_text_variables
from pretix.base.templatetags.money import money_filter
@@ -118,6 +125,14 @@ DEFAULT_VARIABLES = OrderedDict((
"SHORT_DATETIME_FORMAT"
) if ev.date_to else ""
}),
("event_end_date", {
"label": _("Event end date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format(
ev.date_to.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT"
) if ev.date_to else ""
}),
("event_end_time", {
"label": _("Event end time"),
"editor_sample": _("22:00"),
@@ -178,9 +193,65 @@ 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 ""
}),
))
@receiver(layout_text_variables, dispatch_uid="pretix_base_layout_text_variables_questions")
def variables_from_questions(sender, *args, **kwargs):
def get_answer(op, order, event, question_id):
try:
if 'answers' in getattr(op, '_prefetched_objects_cache', {}):
a = [a for a in op.answers.all() if a.question_id == question_id][0]
else:
a = op.answers.get(question_id=question_id)
return str(a).replace("\n", "<br/>\n")
except QuestionAnswer.DoesNotExist:
return ""
except IndexError:
return ""
d = {}
for q in sender.questions.all():
d['question_{}'.format(q.pk)] = {
'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk)
}
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)
@@ -189,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'])
@@ -199,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):
@@ -215,8 +286,10 @@ class Renderer:
self.background_file = background_file
self.variables = get_variables(event)
if self.background_file:
self.bg_pdf = PdfFileReader(BytesIO(self.background_file.read()), strict=False)
self.bg_bytes = self.background_file.read()
self.bg_pdf = PdfFileReader(BytesIO(self.bg_bytes), strict=False)
else:
self.bg_bytes = None
self.bg_pdf = None
@classmethod
@@ -329,24 +402,45 @@ class Renderer:
self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3]))
canvas.showPage()
def render_background(self, buffer, title=_('Ticket')):
from PyPDF2 import PdfFileWriter, PdfFileReader
buffer.seek(0)
new_pdf = PdfFileReader(buffer)
output = PdfFileWriter()
if settings.PDFTK:
buffer.seek(0)
with tempfile.TemporaryDirectory() as d:
with open(os.path.join(d, 'back.pdf'), 'wb') as f:
f.write(self.bg_bytes)
with open(os.path.join(d, 'front.pdf'), 'wb') as f:
f.write(buffer.read())
subprocess.run([
settings.PDFTK,
os.path.join(d, 'front.pdf'),
'background',
os.path.join(d, 'back.pdf'),
'output',
os.path.join(d, 'out.pdf'),
'compress'
], check=True)
with open(os.path.join(d, 'out.pdf'), 'rb') as f:
return BytesIO(f.read())
else:
from PyPDF2 import PdfFileWriter, PdfFileReader
buffer.seek(0)
new_pdf = PdfFileReader(buffer)
output = PdfFileWriter()
for page in new_pdf.pages:
bg_page = copy.copy(self.bg_pdf.getPage(0))
bg_page.mergePage(page)
output.addPage(bg_page)
for page in new_pdf.pages:
bg_page = copy.copy(self.bg_pdf.getPage(0))
bg_page.mergePage(page)
output.addPage(bg_page)
output.addMetadata({
'/Title': str(title),
'/Creator': 'pretix',
})
outbuffer = BytesIO()
output.write(outbuffer)
outbuffer.seek(0)
return outbuffer
output.addMetadata({
'/Title': str(title),
'/Creator': 'pretix',
})
outbuffer = BytesIO()
output.write(outbuffer)
outbuffer.seek(0)
return outbuffer

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

@@ -4,6 +4,7 @@ from decimal import Decimal
from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Q
from django.dispatch import receiver
@@ -17,9 +18,11 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.templatetags.rich_text import rich_text
from pretix.celery_app import app
from pretix.presale.signals import (
@@ -99,7 +102,8 @@ class CartManager:
AddOperation: 30
}
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None):
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None,
sales_channel='web'):
self.event = event
self.cart_id = cart_id
self.now_dt = now()
@@ -111,6 +115,8 @@ class CartManager:
self._variations_cache = {}
self._expiry = None
self.invoice_address = invoice_address
self._widget_data = widget_data or {}
self._sales_channel = sales_channel
@property
def positions(self):
@@ -137,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
@@ -188,6 +196,9 @@ class CartManager:
if not op.item.is_available() or (op.variation and not op.variation.active):
raise CartError(error_messages['unavailable'])
if self._sales_channel not in op.item.sales_channels:
raise CartError(error_messages['unavailable'])
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
raise CartError(error_messages['voucher_invalid_item'])
@@ -605,12 +616,37 @@ class CartManager:
if isinstance(op, self.AddOperation):
for k in range(available_count):
new_cart_positions.append(CartPosition(
cp = CartPosition(
event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax
))
)
if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
if 'attendee-name' in self._widget_data:
cp.attendee_name_parts = {'_legacy': self._widget_data['attendee-name']}
if any('attendee-name-{}'.format(k.replace('_', '-')) in self._widget_data for k, l, w
in scheme['fields']):
cp.attendee_name_parts = {
k: self._widget_data.get('attendee-name-{}'.format(k.replace('_', '-')), '')
for k, l, w in scheme['fields']
}
if self.event.settings.attendee_emails_asked and 'email' in self._widget_data:
cp.attendee_email = self._widget_data.get('email')
cp._answers = {}
for k, v in self._widget_data.items():
if not k.startswith('question-'):
continue
q = cp.item.questions.filter(ask_during_checkin=False, identifier__iexact=k[9:]).first()
if q:
try:
cp._answers[q] = q.clean_answer(v)
except ValidationError:
pass
new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
op.position.expires = self._expiry
@@ -621,7 +657,11 @@ class CartManager:
else:
raise AssertionError("ExtendOperation cannot affect more than one item")
CartPosition.objects.bulk_create(new_cart_positions)
for p in new_cart_positions:
if p._answers:
p.save()
_save_answers(p, {}, p._answers)
CartPosition.objects.bulk_create([p for p in new_cart_positions if not p._answers])
return err
def commit(self):
@@ -702,7 +742,7 @@ def get_fees(event, request, total, invoice_address, provider):
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None) -> None:
invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
@@ -722,7 +762,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
sales_channel=sales_channel)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException:
@@ -774,7 +815,7 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None) -> None:
invoice_address: int=None, sales_channel='web') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
@@ -792,7 +833,7 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc
pass
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
cm.set_addons(addons)
cm.commit()
except LockTimeoutException:

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

@@ -3,13 +3,17 @@ from datetime import timedelta
from django.dispatch import receiver
from django.utils.timezone import now
from pretix.base.models import CachedCombinedTicket, CachedTicket
from ..models import CachedFile, CartPosition, InvoiceAddress
from ..signals import periodic_task
@receiver(signal=periodic_task)
def clean_cart_positions(sender, **kwargs):
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14)):
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=False):
cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete()
for ia in InvoiceAddress.objects.filter(order__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete()
@@ -19,3 +23,15 @@ def clean_cart_positions(sender, **kwargs):
def clean_cached_files(sender, **kwargs):
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
cf.delete()
@receiver(signal=periodic_task)
def clean_cached_tickets(sender, **kwargs):
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
cf.delete()
for cf in CachedCombinedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
cf.delete()
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(minutes=30), file__isnull=True):
cf.delete()
for cf in CachedCombinedTicket.objects.filter(created__lte=now() - timedelta(minutes=30), file__isnull=True):
cf.delete()

View File

@@ -53,6 +53,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if open_payment and open_payment.payment_provider:
payment = open_payment.payment_provider.render_invoice_text(invoice.order)
elif invoice.order.status == Order.STATUS_PAID:
payment = pgettext('invoice', 'The payment for this invoice has already been received.')
else:
payment = ""
@@ -79,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
@@ -232,7 +235,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
@@ -308,6 +311,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
@@ -170,8 +171,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 +220,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

@@ -31,7 +31,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,
)
@@ -91,59 +91,45 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
"""
if new_date < now():
raise OrderError(_('The new expiry date needs to be in the future.'))
if order.status == Order.STATUS_PENDING:
def change(was_expired=True):
order.expires = new_date
order.save(update_fields=['expires'])
if was_expired:
order.status = Order.STATUS_PENDING
order.save(update_fields=['expires'] + (['status'] if was_expired else []))
order.log_action(
'pretix.event.order.expirychanged',
user=user,
auth=auth,
data={
'expires': order.expires,
'state_change': False
'state_change': was_expired
}
)
if was_expired:
num_invoices = order.invoices.filter(is_cancellation=False).count()
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices:
generate_invoice(order)
if order.status == Order.STATUS_PENDING:
change(was_expired=False)
else:
with order.event.lock() as now_dt:
is_available = order._is_still_available(now_dt, count_waitinglist=False)
if is_available is True or force is True:
order.expires = new_date
order.status = Order.STATUS_PENDING
order.save(update_fields=['expires', 'status'])
order.log_action(
'pretix.event.order.expirychanged',
user=user,
auth=auth,
data={
'expires': order.expires,
'state_change': True
}
)
change(was_expired=True)
else:
raise OrderError(is_available)
@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
@@ -307,7 +293,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
@@ -323,20 +310,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=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=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
@@ -379,7 +400,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter()
for i, cp in enumerate(positions):
if not cp.item.active or (cp.variation and not cp.variation.active):
if not cp.item.is_available() or (cp.variation and not cp.variation.active):
err = err or error_messages['unavailable']
cp.delete()
continue
@@ -499,7 +520,7 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None):
meta_info: dict=None, sales_channel: str='web'):
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
@@ -512,7 +533,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
locale=locale,
total=total,
meta_info=json.dumps(meta_info or {}),
require_approval=any(p.item.require_approval for p in positions)
require_approval=any(p.item.require_approval for p in positions),
sales_channel=sales_channel
)
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
order.save()
@@ -532,7 +554,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,
@@ -542,6 +564,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
OrderPosition.transform_cart_positions(positions, order)
order.log_action('pretix.event.order.placed')
if order.require_approval:
order.log_action('pretix.event.order.placed.require_approval')
if meta_info:
for msg in meta_info.get('confirm_messages', []):
order.log_action('pretix.event.order.consent', data={'msg': msg})
@@ -551,7 +575,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None):
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
event = Event.objects.get(id=event)
if payment_provider:
@@ -580,7 +604,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
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)
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):
@@ -661,7 +685,9 @@ def send_expiry_warnings(sender, **kwargs):
eventcache = {}
today = now().replace(hour=0, minute=0, second=0)
for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).only('pk'):
for o in Order.objects.filter(
expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING, datetime__lte=now() - timedelta(hours=2)
).only('pk'):
with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
@@ -723,7 +749,7 @@ def send_download_reminders(sender, **kwargs):
if now() < reminder_date:
continue
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False).only('pk'):
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False, datetime__lte=now() - timedelta(hours=2)).only('pk'):
with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
if o.download_reminder_sent:
@@ -933,6 +959,21 @@ class OrderChangeManager:
if self.order.pending_sum <= Decimal('0.00'):
self.order.status = Order.STATUS_PAID
self.order.save()
elif self.open_payment:
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
self.open_payment.save()
self.order.log_action('pretix.event.order.payment.canceled', {
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
}, user=self.user, auth=self.auth)
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
if self.open_payment:
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
self.open_payment.save()
self.order.log_action('pretix.event.order.payment.canceled', {
'local_id': self.open_payment.local_id,
'provider': self.open_payment.provider,
}, user=self.user, auth=self.auth)
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)):
@@ -1024,6 +1065,10 @@ class OrderChangeManager:
'addon_to': opa.addon_to_id,
'old_price': opa.price,
})
opa.canceled = True
if opa.voucher:
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=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,
@@ -1032,7 +1077,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=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,
@@ -1097,7 +1145,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:
@@ -1303,11 +1351,13 @@ class OrderChangeManager:
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: str, payment_provider: str, positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None):
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web'):
with language(locale):
try:
try:
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
sales_channel)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
@@ -1316,10 +1366,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
else:
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

@@ -1,6 +1,5 @@
import logging
import os
from datetime import timedelta
from django.core.files.base import ContentFile
from django.utils.timezone import now
@@ -20,54 +19,48 @@ from pretix.helpers.database import rolledback_transaction
logger = logging.getLogger(__name__)
@app.task(base=ProfiledTask)
def generate(order_position: str, provider: str):
def generate_orderposition(order_position: int, provider: str):
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
try:
ct = CachedTicket.objects.get(order_position=order_position, provider=provider)
except CachedTicket.MultipleObjectsReturned:
CachedTicket.objects.filter(order_position=order_position, provider=provider).delete()
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='',
type='', file=None)
except CachedTicket.DoesNotExist:
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='',
type='', file=None)
with language(order_position.order.locale):
responses = register_ticket_outputs.send(order_position.order.event)
for receiver, response in responses:
prov = response(order_position.order.event)
if prov.identifier == provider:
filename, ct.type, data = prov.generate(order_position)
filename, ttype, data = prov.generate(order_position)
path, ext = os.path.splitext(filename)
ct.extension = ext
ct.save()
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
ct.delete()
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
extension=ext, type=ttype, file=None)
ct.file.save(filename, ContentFile(data))
return ct.pk
@app.task(base=ProfiledTask)
def generate_order(order: int, provider: str):
order = Order.objects.select_related('event').get(id=order)
try:
ct = CachedCombinedTicket.objects.get(order=order, provider=provider)
except CachedCombinedTicket.MultipleObjectsReturned:
CachedCombinedTicket.objects.filter(order=order, provider=provider).delete()
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension='',
type='', file=None)
except CachedCombinedTicket.DoesNotExist:
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension='',
type='', file=None)
with language(order.locale):
responses = register_ticket_outputs.send(order.event)
for receiver, response in responses:
prov = response(order.event)
if prov.identifier == provider:
filename, ct.type, data = prov.generate_order(order)
filename, ttype, data = prov.generate_order(order)
path, ext = os.path.splitext(filename)
ct.extension = ext
ct.save()
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
ct.delete()
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
type=ttype, file=None)
ct.file.save(filename, ContentFile(data))
return ct.pk
@app.task(base=ProfiledTask)
def generate(model: str, pk: int, provider: str):
if model == 'order':
return generate_order(pk, provider)
elif model == 'orderposition':
return generate_orderposition(pk, provider)
class DummyRollbackException(Exception):
@@ -103,56 +96,6 @@ def preview(event: int, provider: str):
return prov.generate(p)
def get_cachedticket_for_position(pos, identifier, generate_async=True):
apply_method = 'apply_async' if generate_async else 'apply'
try:
ct = CachedTicket.objects.filter(
order_position=pos, provider=identifier
).last()
except CachedTicket.DoesNotExist:
ct = None
if not ct:
ct = CachedTicket.objects.create(
order_position=pos, provider=identifier,
extension='', type='', file=None)
getattr(generate, apply_method)(args=(pos.id, identifier))
if not generate_async:
ct.refresh_from_db()
if not ct.file:
if now() - ct.created > timedelta(minutes=5):
getattr(generate, apply_method)(args=(pos.id, identifier))
if not generate_async:
ct.refresh_from_db()
return ct
def get_cachedticket_for_order(order, identifier, generate_async=True):
apply_method = 'apply_async' if generate_async else 'apply'
try:
ct = CachedCombinedTicket.objects.filter(
order=order, provider=identifier
).last()
except CachedCombinedTicket.DoesNotExist:
ct = None
if not ct:
ct = CachedCombinedTicket.objects.create(
order=order, provider=identifier,
extension='', type='', file=None)
getattr(generate_order, apply_method)(args=(order.id, identifier))
if not generate_async:
ct.refresh_from_db()
if not ct.file:
if now() - ct.created > timedelta(minutes=5):
getattr(generate_order, apply_method)(args=(order.id, identifier))
if not generate_async:
ct.refresh_from_db()
return ct
def get_tickets_for_order(order):
can_download = all([r for rr, r in allow_ticket_download.send(order.event, order=order)])
if not can_download:
@@ -174,7 +117,14 @@ def get_tickets_for_order(order):
if p.multi_download_enabled:
try:
ct = get_cachedticket_for_order(order, p.identifier, generate_async=False)
if len(order.positions_with_tickets) == 0:
continue
ct = CachedCombinedTicket.objects.filter(
order=order, provider=p.identifier, file__isnull=False
).last()
if not ct or not ct.file:
retval = generate.apply(args=('order', order.pk, p.identifier))
ct = CachedCombinedTicket.objects.get(pk=retval.get())
tickets.append((
"{}-{}-{}{}".format(
order.event.slug.upper(), order.code, ct.provider, ct.extension,
@@ -184,13 +134,14 @@ 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 = get_cachedticket_for_position(pos, p.identifier, generate_async=False)
ct = CachedTicket.objects.filter(
order_position=pos, provider=p.identifier, file__isnull=False
).last()
if not ct or not ct.file:
retval = generate.apply(args=('orderposition', pos.pk, p.identifier))
ct = CachedTicket.objects.get(pk=retval.get())
tickets.append((
"{}-{}-{}-{}{}".format(
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,

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
@@ -483,6 +520,14 @@ Your {event} team"""))
'default': '#8E44B3',
'type': str
},
'theme_color_success': {
'default': '#50A167',
'type': str
},
'theme_color_danger': {
'default': '#D36060',
'type': str
},
'primary_font': {
'default': 'Open Sans',
'type': str
@@ -515,6 +560,10 @@ Your {event} team"""))
'default': '',
'type': LazyI18nString
},
'voucher_explanation_text': {
'default': '',
'type': LazyI18nString
},
'organizer_info_text': {
'default': '',
'type': LazyI18nString

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):
@@ -178,6 +221,15 @@ however for this signal, the ``sender`` **may also be None** to allow creating t
notification settings!
"""
register_sales_channels = django.dispatch.Signal(
providing_args=[]
)
"""
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
instances.
"""
register_data_exporters = EventPluginSignal(
providing_args=[]
)

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 request.sentry.id %}
<p>
{% blocktrans trimmed %}
If you contact us, please send us the following code:
{% endblocktrans %}
<br>
{{ request.sentry.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

@@ -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

@@ -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

@@ -8,11 +8,14 @@ from django.utils.translation import get_language
from pretix.base.models.auth import StaffSession
from pretix.base.settings import GlobalSettingsObject
from pretix.control.navigation import (
get_event_navigation, get_global_navigation, get_organizer_navigation,
)
from ..helpers.i18n import (
get_javascript_format, get_javascript_output_format, get_moment_locale,
)
from .signals import html_head, nav_event, nav_global, nav_topbar
from .signals import html_head, nav_topbar
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
@@ -31,6 +34,7 @@ def contextprocessor(request):
ctx = {
'url_name': url.url_name,
'settings': settings,
'django_settings': settings,
'DEBUG': settings.DEBUG,
}
_html_head = []
@@ -40,10 +44,9 @@ def contextprocessor(request):
ctx['html_head'] = "".join(_html_head)
_js_payment_weekdays_disabled = '[]'
_nav_event = []
if getattr(request, 'event', None) and hasattr(request, 'organizer') and request.user.is_authenticated:
for receiver, response in nav_event.send(request.event, request=request):
_nav_event += response
ctx['nav_items'] = get_event_navigation(request)
if request.event.settings.get('payment_term_weekdays'):
_js_payment_weekdays_disabled = '[0,6]'
@@ -65,17 +68,13 @@ def contextprocessor(request):
if request.GET.get('subevent', ''):
# Do not use .get() for lazy evaluation
ctx['selected_subevents'] = request.event.subevents.filter(pk=request.GET.get('subevent'))
elif getattr(request, 'organizer', None) and request.user.is_authenticated:
ctx['nav_items'] = get_organizer_navigation(request)
elif request.user.is_authenticated:
ctx['nav_items'] = get_global_navigation(request)
ctx['nav_event'] = _nav_event
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
_nav_global = []
if not hasattr(request, 'event') and request.user.is_authenticated:
for receiver, response in nav_global.send(request, request=request):
_nav_global += response
ctx['nav_global'] = sorted(_nav_global, key=lambda n: n['label'])
_nav_topbar = []
if request.user.is_authenticated:
for receiver, response in nav_topbar.send(request, request=request):

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 "
@@ -938,7 +992,27 @@ class DisplaySettingsForm(SettingsForm):
required=False,
validators=[
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
message=_('Please enter the hexadecimal code of a color, e.g. #990000.'))
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
],
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
)
theme_color_success = forms.CharField(
label=_("Accent color for success"),
help_text=_("We strongly suggest to use a shade of green."),
required=False,
validators=[
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
],
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
)
theme_color_danger = forms.CharField(
label=_("Accent color for errors"),
help_text=_("We strongly suggest to use a dark shade of red."),
required=False,
validators=[
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
],
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
)
@@ -961,6 +1035,14 @@ class DisplaySettingsForm(SettingsForm):
required=False,
widget=I18nTextarea
)
voucher_explanation_text = I18nFormField(
label=_("Voucher explanation"),
required=False,
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain "
"how to obtain a voucher code.")
)
show_variations_expanded = forms.BooleanField(
label=_("Show variations of a product expanded by default"),
required=False

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,
)
@@ -116,8 +118,8 @@ class OrderFilterForm(FilterForm):
u = fdata.get('query')
if "-" in u:
code = (Q(event__slug__icontains=u.split("-")[0])
& Q(code__icontains=Order.normalize_code(u.split("-")[1])))
code = (Q(event__slug__icontains=u.rsplit("-", 1)[0])
& Q(code__icontains=Order.normalize_code(u.rsplit("-", 1)[1])))
else:
code = Q(code__icontains=Order.normalize_code(u))
@@ -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,14 +200,13 @@ 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')),
('pa', _('Approval pending')),
('overpaid', _('Overpaid')),
('underpaid', _('Underpaid')),
@@ -243,10 +244,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)
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)
if fdata.get('question') and fdata.get('answer') is not None:
q = fdata.get('question')
@@ -274,16 +275,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
@@ -299,7 +303,7 @@ class EventOrderFilterForm(OrderFilterForm):
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 +359,11 @@ class SubEventFilterForm(FilterForm):
),
required=False
)
date = forms.DateField(
label=_('Date'),
required=False,
widget=DatePickerWidget
)
weekday = forms.ChoiceField(
label=_('Weekday'),
choices=(
@@ -378,6 +387,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 +420,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

@@ -8,6 +8,7 @@ from django.utils.translation import (
)
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nFormSet, I18nModelForm
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
@@ -226,6 +227,10 @@ class ItemCreateForm(I18nModelForm):
self.instance.checkin_attention = self.cleaned_data['copy_from'].checkin_attention
self.instance.free_price = self.cleaned_data['copy_from'].free_price
self.instance.original_price = self.cleaned_data['copy_from'].original_price
self.instance.sales_channels = self.cleaned_data['copy_from'].sales_channels
else:
# Add to all sales channels by default
self.instance.sales_channels = [k for k in get_all_sales_channels().keys()]
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
instance = super().save(*args, **kwargs)
@@ -292,6 +297,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)
@@ -302,6 +317,13 @@ class ItemUpdateForm(I18nModelForm):
'over 65. This ticket includes access to all parts of the event, except the VIP '
'area.'
)
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=_('Sales channels'),
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
widget=forms.CheckboxSelectMultiple
)
change_decimal_field(self.fields['default_price'], self.event.currency)
class Meta:
@@ -312,6 +334,7 @@ class ItemUpdateForm(I18nModelForm):
'name',
'internal_name',
'active',
'sales_channels',
'admission',
'description',
'picture',
@@ -327,6 +350,7 @@ class ItemUpdateForm(I18nModelForm):
'max_per_order',
'min_per_order',
'checkin_attention',
'generate_tickets',
'original_price'
]
field_classes = {
@@ -336,6 +360,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
@@ -49,7 +51,7 @@ class ExtendForm(I18nModelForm):
return data
class MarkPaidForm(forms.Form):
class ConfirmPaymentForm(forms.Form):
force = forms.BooleanField(
label=_('Overbook quota and ignore late payment'),
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
@@ -75,6 +77,54 @@ class MarkPaidForm(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,
max_digits=10, decimal_places=2,
localize=True,
label=_('Payment amount'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['amount'], self.instance.event.currency)
self.fields['amount'].initial = max(Decimal('0.00'), self.instance.pending_sum)
class ExporterForm(forms.Form):
def clean(self):
data = super().clean()
@@ -351,8 +401,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.')),
@@ -375,7 +424,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

@@ -1,3 +1,5 @@
from urllib.parse import urlparse
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -80,6 +82,19 @@ class OrganizerUpdateForm(OrganizerForm):
help_text=_('You need to configure the custom domain in the webserver beforehand.')
)
def clean_domain(self):
d = self.cleaned_data['domain']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.pk).exists():
raise ValidationError(
_('This domain is already in use for a different organizer.')
)
return d
def clean_slug(self):
if self.change_slug:
return self.cleaned_data['slug']
@@ -178,7 +193,28 @@ class OrganizerDisplaySettingsForm(SettingsForm):
required=False,
validators=[
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
message=_('Please enter the hexadecimal code of a color, e.g. #990000.'))
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
],
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
)
theme_color_success = forms.CharField(
label=_("Accent color for success"),
help_text=_("We strongly suggest to use a shade of green."),
required=False,
validators=[
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
],
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
)
theme_color_danger = forms.CharField(
label=_("Accent color for errors"),
help_text=_("We strongly suggest to use a shade of red."),
required=False,
validators=[
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
],
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
)
@@ -202,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
@@ -219,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

@@ -2,6 +2,7 @@ from datetime import timedelta
from django import forms
from django.forms import formset_factory
from django.utils.dates import MONTHS, WEEKDAYS
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
@@ -276,13 +277,13 @@ class RRuleForm(forms.Form):
)
yearly_byweekday = forms.ChoiceField(
choices=[
('MO', _('Monday')),
('TU', _('Tuesday')),
('WE', _('Wednesday')),
('TH', _('Thursday')),
('FR', _('Friday')),
('SA', _('Saturday')),
('SU', _('Sunday')),
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
@@ -291,18 +292,7 @@ class RRuleForm(forms.Form):
)
yearly_bymonth = forms.ChoiceField(
choices=[
('1', _('January')),
('2', _('February')),
('3', _('March')),
('4', _('April')),
('5', _('May')),
('6', _('June')),
('7', _('July')),
('8', _('August')),
('9', _('September')),
('10', _('October')),
('11', _('November')),
('12', _('December')),
(str(i), MONTHS[i]) for i in range(1, 13)
],
required=False
)
@@ -326,13 +316,13 @@ class RRuleForm(forms.Form):
)
monthly_byweekday = forms.ChoiceField(
choices=[
('MO', _('Monday')),
('TU', _('Tuesday')),
('WE', _('Wednesday')),
('TH', _('Thursday')),
('FR', _('Friday')),
('SA', _('Saturday')),
('SU', _('Sunday')),
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
@@ -342,13 +332,13 @@ class RRuleForm(forms.Form):
weekly_byweekday = forms.MultipleChoiceField(
choices=[
('MO', _('Monday')),
('TU', _('Tuesday')),
('WE', _('Wednesday')),
('TH', _('Thursday')),
('FR', _('Friday')),
('SA', _('Saturday')),
('SU', _('Sunday')),
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
],
required=False,
widget=forms.CheckboxSelectMultiple

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

@@ -6,7 +6,10 @@ import bleach
import dateutil.parser
import pytz
from django.dispatch import receiver
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.strings import LazyI18nString
@@ -60,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),
@@ -91,12 +94,17 @@ 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}) split into new order: {order}').format(
old_item=old_item,
url = reverse('control:event.order', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'code': data['new_order']
})
return mark_safe(escape(text) + ' ' + _('Position #{posid} ({old_item}, {old_price}) split into new order: {order}').format(
old_item=escape(old_item),
posid=data.get('positionid', '?'),
order=data['new_order'],
order='<a href="{}">{}</a>'.format(url, data['new_order']),
old_price=money_filter(Decimal(data['old_price']), event.currency),
)
))
elif logentry.action_type == 'pretix.event.order.changed.split_from':
return _('This order has been created by splitting the order {order}').format(
order=data['original_order'],
@@ -165,6 +173,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.canceled': _('The order has been canceled.'),
'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.'),
'pretix.event.order.denied': _('The order has been denied.'),
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
@@ -178,6 +187,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.'),
@@ -202,8 +212,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.'),

View File

@@ -0,0 +1,479 @@
from django.http import HttpRequest
from django.urls import reverse
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.control.signals import (
nav_event, nav_event_settings, nav_global, nav_organizer,
)
def get_event_navigation(request: HttpRequest):
url = request.resolver_match
if not url:
return []
nav = [
{
'label': _('Dashboard'),
'url': reverse('control:event.index', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': (url.url_name == 'event.index'),
'icon': 'dashboard',
}
]
if 'can_change_event_settings' in request.eventpermset:
event_settings = [
{
'label': _('General'),
'url': reverse('control:event.settings', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings',
},
{
'label': _('Payment'),
'url': reverse('control:event.settings.payment', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.payment',
},
{
'label': _('Plugins'),
'url': reverse('control:event.settings.plugins', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.plugins',
},
{
'label': _('Display'),
'url': reverse('control:event.settings.display', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.display',
},
{
'label': _('Tickets'),
'url': reverse('control:event.settings.tickets', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.tickets',
},
{
'label': _('E-mail'),
'url': reverse('control:event.settings.mail', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.mail',
},
{
'label': _('Tax rules'),
'url': reverse('control:event.settings.tax', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.tax',
},
{
'label': _('Invoicing'),
'url': reverse('control:event.settings.invoice', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'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={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.widget',
},
]
event_settings += sorted(
sum((list(a[1]) for a in nav_event_settings.send(request.event, request=request)), []),
key=lambda r: r['label']
)
nav.append({
'label': _('Settings'),
'url': reverse('control:event.settings', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': False,
'icon': 'wrench',
'children': event_settings
})
if request.event.has_subevents:
nav.append({
'label': pgettext_lazy('subevent', 'Dates'),
'url': reverse('control:event.subevents', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': ('event.subevent' in url.url_name),
'icon': 'calendar',
})
if 'can_change_items' in request.eventpermset:
nav.append({
'label': _('Products'),
'url': reverse('control:event.items', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': False,
'icon': 'ticket',
'children': [
{
'label': _('Products'),
'url': reverse('control:event.items', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name in (
'event.item', 'event.items.add', 'event.items') or "event.item." in url.url_name,
},
{
'label': _('Quotas'),
'url': reverse('control:event.items.quotas', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.quota' in url.url_name,
},
{
'label': _('Categories'),
'url': reverse('control:event.items.categories', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.categories' in url.url_name,
},
{
'label': _('Questions'),
'url': reverse('control:event.items.questions', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.questions' in url.url_name,
},
]
})
if 'can_view_orders' in request.eventpermset:
nav.append({
'label': _('Orders'),
'url': reverse('control:event.orders', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': False,
'icon': 'shopping-cart',
'children': [
{
'label': _('All orders'),
'url': reverse('control:event.orders', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name in ('event.orders', 'event.order') or "event.order." in url.url_name,
},
{
'label': _('Overview'),
'url': reverse('control:event.orders.overview', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.overview' in url.url_name,
},
{
'label': _('Refunds'),
'url': reverse('control:event.orders.refunds', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.refunds' in url.url_name,
},
{
'label': _('Export'),
'url': reverse('control:event.orders.export', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.export' in url.url_name,
},
{
'label': _('Waiting list'),
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.waitinglist' in url.url_name,
},
]
})
if 'can_view_vouchers' in request.eventpermset:
nav.append({
'label': _('Vouchers'),
'url': reverse('control:event.vouchers', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': False,
'icon': 'tags',
'children': [
{
'label': _('All vouchers'),
'url': reverse('control:event.vouchers', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name != 'event.vouchers.tags' and "event.vouchers" in url.url_name,
},
{
'label': _('Tags'),
'url': reverse('control:event.vouchers.tags', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.vouchers.tags' in url.url_name,
},
]
})
if 'can_view_orders' in request.eventpermset:
nav.append({
'label': pgettext_lazy('navigation', 'Check-in'),
'url': reverse('control:event.orders.checkinlists', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': False,
'icon': 'check-square-o',
'children': [
{
'label': _('Check-in lists'),
'url': reverse('control:event.orders.checkinlists', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.checkin' in url.url_name,
},
]
})
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_event.send(request.event, request=request)), []),
key=lambda r: r['label']
))
return nav
def get_global_navigation(request):
url = request.resolver_match
if not url:
return []
has_staff_session = request.user.has_active_staff_session(request.session.session_key)
nav = [
{
'label': _('Dashboard'),
'url': reverse('control:index'),
'active': (url.url_name == 'index'),
'icon': 'dashboard',
},
{
'label': _('Events'),
'url': reverse('control:events'),
'active': 'events' in url.url_name,
'icon': 'calendar',
},
{
'label': _('Organizers'),
'url': reverse('control:organizers'),
'active': 'organizers' in url.url_name,
'icon': 'group',
},
{
'label': _('Order search'),
'url': reverse('control:search.orders'),
'active': 'search.orders' in url.url_name,
'icon': 'search',
},
{
'label': _('User settings'),
'url': reverse('control:user.settings'),
'active': False,
'icon': 'user',
'children': [
{
'label': _('General'),
'url': reverse('control:user.settings'),
'active': 'user.settings' == url.url_name,
},
{
'label': _('Notifications'),
'url': reverse('control:user.settings.notifications'),
'active': 'user.settings.notifications' == url.url_name,
},
{
'label': _('2FA'),
'url': reverse('control:user.settings.2fa'),
'active': 'user.settings.2fa' in url.url_name,
},
{
'label': _('Authorized apps'),
'url': reverse('control:user.settings.oauth.list'),
'active': 'user.settings.oauth' in url.url_name,
},
{
'label': _('Account history'),
'url': reverse('control:user.settings.history'),
'active': 'user.settings.history' in url.url_name,
},
]
},
]
if has_staff_session:
nav.append({
'label': _('Users'),
'url': reverse('control:users'),
'active': False,
'icon': 'user',
'children': [
{
'label': _('All users'),
'url': reverse('control:users'),
'active': ('users' in url.url_name),
},
{
'label': _('Admin sessions'),
'url': reverse('control:user.sudo.list'),
'active': ('sudo' in url.url_name),
},
]
})
nav.append({
'label': _('Global settings'),
'url': reverse('control:global.settings'),
'active': False,
'icon': 'wrench',
'children': [
{
'label': _('Settings'),
'url': reverse('control:global.settings'),
'active': (url.url_name == 'global.settings'),
},
{
'label': _('Update check'),
'url': reverse('control:global.update'),
'active': (url.url_name == 'global.update'),
},
]
})
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_global.send(request, request=request)), []),
key=lambda r: r['label']
))
return nav
def get_organizer_navigation(request):
url = request.resolver_match
if not url:
return []
nav = [
{
'label': _('Events'),
'url': reverse('control:organizer', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name == 'organizer',
'icon': 'calendar',
},
]
if 'can_change_organizer_settings' in request.orgapermset:
nav.append({
'label': _('Settings'),
'url': reverse('control:organizer.edit', kwargs={
'organizer': request.organizer.slug
}),
'icon': 'wrench',
'children': [
{
'label': _('General'),
'url': reverse('control:organizer.edit', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name == 'organizer.edit',
},
{
'label': _('Display'),
'url': reverse('control:organizer.display', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name == 'organizer.display',
},
]
})
if 'can_change_teams' in request.orgapermset:
nav.append({
'label': _('Teams'),
'url': reverse('control:organizer.teams', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.team' in url.url_name,
'icon': 'group',
})
if 'can_change_organizer_settings' in request.orgapermset:
nav.append({
'label': _('Devices'),
'url': reverse('control:organizer.devices', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.device' in url.url_name,
'icon': 'tablet',
})
nav.append({
'label': _('Webhooks'),
'url': reverse('control:organizer.webhooks', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.webhook' in url.url_name,
'icon': 'bolt',
})
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
[]),
key=lambda r: r['label']
))
return nav
def merge_in(nav, newnav):
for item in newnav:
if 'parent' in item:
parents = [n for n in nav if n['url'] == item['parent']]
if parents:
parents[0]['children'].append(item)
else:
nav.append(item)

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

@@ -46,7 +46,6 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quicksetup.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asyncdownload.js" %}"></script>
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
@@ -65,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>
@@ -82,10 +82,6 @@
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<button type="button" class="navbar-toggle navbar-events"
data-toggle="collapse" data-target=".navbar-events-collapse">
<i class="fa fa-calendar"></i><span class="caret"></span>
</button>
{% if request.event %}
{% if has_domain and not request.event.live %}
<form action="{% eventurl request.event "presale:event.auth" %}" method="post"
@@ -108,24 +104,6 @@
</a>
</div>
<ul class="nav navbar-nav navbar-top-links navbar-left hidden-xs">
<li class="dropdown">
<a href="#" class="dropdown-toggle event-dropdown-toggle" data-toggle="dropdown"><i class="fa fa-calendar"></i>
<div class="event-indicator">
<span class="event-name">{{ request.event }}</span>
<span class="event-daterange">{{ request.event.get_date_range_display }}</span>
</div>
<span class="caret"></span></a>
<ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead
data-source="{% url "control:events.typeahead" %}">
<li class="query-holder">
<div class="form-box">
<input type="text" class="form-control"
placeholder="{% trans "Search for events" %}"
data-typeahead-query>
</div>
</li>
</ul>
</li>
{% if request.event %}
<li>
{% if has_domain and not request.event.live %}
@@ -209,71 +187,56 @@
</li>
</ul>
<div class="navbar-default sidebar" role="navigation">
<div class="sidebar-nav navbar-events-collapse navbar-collapse hidden-sm hidden-md hidden-lg mobile-event-dropdown">
<ul class="nav" data-event-typeahead data-source="{% url "control:events.typeahead" %}">
<li class="query-holder">
<div class="form-box">
<input type="text" class="form-control"
placeholder="{% trans "Search for events" %}"
data-typeahead-query>
</div>
</li>
</ul>
</div>
<div class="sidebar-nav navbar-nav-collapse navbar-collapse">
<div class="dropdown context-selector">
{% if request.event %}
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-calendar fa-stack-1x fa-inverse"></i>
</span>
<div class="context-indicator">
<span class="context-name">{{ request.event }}</span>
<span class="context-meta">{{ request.event.get_date_range_display }}</span>
</div>
<span class="caret"></span></a>
{% elif request.organizer %}
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-group fa-stack-1x fa-inverse"></i>
</span>
<div class="context-indicator">
<span class="context-name">{{ request.organizer }}</span>
<span class="context-meta">{% trans "Organizer account" %}</span>
</div>
<span class="caret"></span></a>
{% else %}
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x"></i>
<i class="fa fa-user fa-stack-1x fa-inverse"></i>
</span>
<div class="context-indicator">
<span class="context-name">{{ request.user }}</span>
</div>
<span class="caret"></span></a>
{% endif %}
<ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead
data-source="{% url "control:nav.typeahead" %}">
<li class="query-holder">
<div class="form-box">
<input type="text" class="form-control" id="event-dropdown-field"
placeholder="{% trans "Search for events" %}"
data-typeahead-query>
</div>
</li>
</ul>
</div>
<ul class="nav" id="side-menu">
{% block nav %}
<li>
<a href="{% url 'control:index' %}" {% if url_name == "index" %}class="active"{% endif %}>
<i class="fa fa-dashboard fa-fw"></i>
{% trans "Dashboard" %}
</a>
</li>
{% if staff_session %}
<li>
<a href="{% url 'control:global.settings' %}"
{% if "global.settings" in url_name %}class="active"{% endif %}>
<i class="fa fa-wrench fa-fw"></i>
{% trans "Global settings" %}
</a>
</li>
{% endif %}
<li>
<a href="{% url 'control:events' %}" {% if "events" in url_name %}class="active"{% endif %}>
<i class="fa fa-calendar fa-fw"></i>
{% trans "Events" %}
</a>
</li>
<li>
<a href="{% url 'control:organizers' %}" {% if "organizer" in url_name %}class="active"{% endif %}>
<i class="fa fa-users fa-fw"></i>
{% trans "Organizers" %}
</a>
</li>
<li>
<a href="{% url 'control:search.orders' %}"
{% if url_name == "search.orders" %}class="active"{% endif %}>
<i class="fa fa-search fa-fw"></i>
{% trans "Order search" %}
</a>
</li>
{% if staff_session %}
<li>
<a href="{% url 'control:users' %}"
{% if "users" in url_name %}class="active"{% endif %}>
<i class="fa fa-user fa-fw"></i>
{% trans "Users" %}
</a>
</li>
<li>
<a href="{% url 'control:user.sudo.list' %}"
{% if "sudo" in url_name %}class="active"{% endif %}>
<i class="fa fa-id-card fa-fw"></i>
{% trans "Admin sessions" %}
</a>
</li>
{% endif %}
{% for nav in nav_global %}
{% for nav in nav_items %}
<li>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>

View File

@@ -8,7 +8,7 @@
<input type="text" class="form-control" id="dashboard_query"
placeholder="{% trans "Go to event" %}"
data-typeahead-query autofocus>
<ul data-event-typeahead data-source="{% url "control:events.typeahead" %}" data-typeahead-field="#dashboard_query"
<ul data-event-typeahead data-source="{% url "control:nav.typeahead" %}" data-typeahead-field="#dashboard_query"
class="event-dropdown dropdown-menu">
</ul>
</div>

View File

@@ -2,158 +2,3 @@
{% load i18n %}
{% load static %}
{% block title %}{{ request.event.name }}{% endblock %}
{% block nav %}
<li>
<a href="{% url 'control:event.index' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.index" %}class="active"{% endif %}>
<i class="fa fa-dashboard fa-fw"></i>
{% trans "Event dashboard" %}
</a>
</li>
{% if 'can_change_event_settings' in request.eventpermset %}
<li>
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if is_event_settings or "event.settings" == url_name or "event.settings." in url_name %}class="active"{% endif %}>
<i class="fa fa-wrench fa-fw"></i>
{% trans "Settings" %}
</a>
</li>
{% if request.event.has_subevents %}
<li>
<a href="{% url 'control:event.subevents' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if "event.subevent" in url_name %}class="active"{% endif %}>
<i class="fa fa-calendar fa-fw"></i>
{% trans "Dates" context "subevent" %}
</a>
</li>
{% endif %}
{% endif %}
{% if 'can_change_items' in request.eventpermset %}
<li>
<a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}"
class="has-children">
<i class="fa fa-ticket fa-fw"></i>
{% trans "Products" %}
</a>
<a href="#" class="arrow">
<span class="fa arrow"></span>
</a>
<ul class="nav nav-second-level">
<li>
<a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if "event.items" == url_name or "event.item." in url_name or "event.items.add" == url_name or url_name == "event.item" %}class="active"{% endif %}>
{% trans "Products" %}</a>
</li>
<li>
<a href="{% url 'control:event.items.quotas' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if "event.items.quotas" in url_name %}class="active"{% endif %}>
{% trans "Quotas" %}
</a>
</li>
<li>
<a href="{% url 'control:event.items.categories' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if "event.items.categories" in url_name %}class="active"{% endif %}>
{% trans "Categories" %}
</a>
</li>
<li>
<a href="{% url 'control:event.items.questions' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if "event.items.questions" in url_name %}class="active"{% endif %}>
{% trans "Questions" %}
</a>
</li>
</ul>
</li>
{% endif %}
{% if 'can_view_orders' in request.eventpermset %}
<li>
<a href="{% url 'control:event.orders' organizer=request.event.organizer.slug event=request.event.slug %}"
class="has-children">
<i class="fa fa-shopping-cart fa-fw"></i>
{% trans "Orders" %}
</a>
<a href="#" class="arrow">
<span class="fa arrow"></span>
</a>
<ul class="nav nav-second-level">
<li>
<a href="{% url 'control:event.orders' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders" or "event.order." in url_name or url_name == "event.order" %}class="active"{% endif %}>
{% trans "All orders" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.overview' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.overview" %}class="active"{% endif %}>
{% trans "Overview" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.refunds' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.refunds" %}class="active"{% endif %}>
{% trans "Refunds" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.export' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.export" %}class="active"{% endif %}>
{% trans "Export" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.waitinglist' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.waitinglist" %}class="active"{% endif %}>
{% trans "Waiting list" %}
</a>
</li>
</ul>
</li>
{% endif %}
{% if 'can_view_vouchers' in request.eventpermset %}
<li>
<a href="{% url 'control:event.vouchers' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.voucher" %}class="active"{% endif %}>
<i class="fa fa-tags fa-fw"></i>
{% trans "Vouchers" %}
</a>
</li>
{% endif %}
{% if 'can_view_orders' in request.eventpermset %}
<li>
<a href="{% url 'control:event.orders.checkinlists' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if "event.orders.checkin" in url_name %}class="active"{% endif %}>
<i class="fa fa-check-square-o fa-fw"></i>
{% trans "Check-in lists" %}
</a>
</li>
{% endif %}
{% for nav in nav_event %}
<li>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>
{% if nav.icon and "." in nav.icon %}
<img src="{% static nav.icon %}" class="fa-img">
{% elif nav.icon %}
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
{% endif %}
{{ nav.label }}
</a>
{% if nav.children %}
<a href="#" class="arrow">
<span class="fa arrow"></span>
</a>
<ul class="nav nav-second-level">
{% for item in nav.children %}
<li>
<a href="{{ item.url }}"
{% if item.active %}class="active"{% endif %}>
{{ item.label }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
{% endblock %}

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

@@ -3,6 +3,7 @@
{% load bootstrap3 %}
{% load hierarkey_form %}
{% block inside %}
<h1>{% trans "Display settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
@@ -10,6 +11,7 @@
<legend>{% trans "Event page" %}</legend>
{% bootstrap_field form.logo_image layout="control" %}
{% bootstrap_field form.frontpage_text layout="control" %}
{% bootstrap_field form.voucher_explanation_text layout="control" %}
{% bootstrap_field form.show_variations_expanded layout="control" %}
{% bootstrap_field form.meta_noindex layout="control" %}
{% if form.frontpage_subevent_ordering %}
@@ -19,8 +21,10 @@
<fieldset>
<legend>{% trans "Shop design" %}</legend>
{% url "control:organizer.display" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "primary_color" "primary_font" %}
{% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" %}
{% bootstrap_field form.primary_color layout="control" %}
{% bootstrap_field form.theme_color_success layout="control" %}
{% bootstrap_field form.theme_color_danger layout="control" %}
{% bootstrap_field form.primary_font layout="control" %}
{% endpropagated %}
</fieldset>

View File

@@ -2,11 +2,12 @@
{% load i18n %}
{% load bootstrap3 %}
{% block inside %}
<h1>{% trans "Invoice settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Invoice settings" %}</legend>
<legend>{% trans "General settings" %}</legend>
{% bootstrap_field form.invoice_generate layout="control" %}
{% bootstrap_field form.invoice_email_attachment layout="control" %}
{% bootstrap_field form.invoice_numbers_prefix layout="control" %}
@@ -22,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,12 +3,13 @@
{% load bootstrap3 %}
{% load static %}
{% block inside %}
<h1>{% trans "E-mail settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
mail-preview-url="{% url "control:event.settings.mail.preview" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "E-mail settings" %}</legend>
<legend>{% trans "General settings" %}</legend>
{% bootstrap_field form.mail_prefix layout="control" %}
{% bootstrap_field form.mail_from layout="control" %}
{% bootstrap_field form.mail_text_signature layout="control" %}

View File

@@ -36,7 +36,7 @@
</div>
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
{% for l in request.event.settings.locales %}
<pre lang="{{ l }}" for="{{ item }}" class="mail-preview"></pre>
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endfor %}
</div>
</div>

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>
@@ -53,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>

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