Compare commits

..

145 Commits

Author SHA1 Message Date
Raphael Michel
dbae184a6f Bump to 3.13.1 2020-12-22 11:41:29 +01:00
Raphael Michel
41ef66917d Properly backport migrations 2020-12-22 11:41:22 +01:00
Raphael Michel
20dbb3dfd0 Reduce lifetime of export files 2020-12-22 11:19:05 +01:00
Raphael Michel
53c71669b9 [SECURITY] Rate limiting for login 2020-12-22 11:19:04 +01:00
Raphael Michel
aa13a927e1 [SECURITY] Rate limiting for password change form 2020-12-22 11:19:04 +01:00
Raphael Michel
6dea8a0e0f [SECURITY] Bind relevant cached file downloads to the current session 2020-12-22 11:19:04 +01:00
Raphael Michel
13441bf3c4 [SECURITY] Fix unvalidated redirect 2020-12-22 11:17:16 +01:00
Raphael Michel
0291676e2d [SECURITY] Prevent phishing through misleading link titles 2020-12-22 11:17:16 +01:00
Raphael Michel
19e5843d99 Bump to 3.13.0 2020-11-24 11:53:08 +01:00
Raphael Michel
4ede99c04b Merge pull request #1855 from pretix-translations/weblate-pretix-pretix 2020-11-24 11:52:33 +01:00
Raphael Michel
987802335b Add Merchandise to German word list 2020-11-24 11:52:18 +01:00
Raphael Michel
eb7e272273 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3850 of 3850 strings)

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

powered by weblate
2020-11-24 11:49:39 +01:00
Raphael Michel
2761419952 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3850 of 3850 strings)

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

powered by weblate
2020-11-24 11:49:38 +01:00
Raphael Michel
4b422571ad Cloning events: Copy *relative* admission time 2020-11-24 10:55:55 +01:00
Richard Schreiber
c340fd9d97 Fix iCal export for full-day events (DTEND is non-inclusive) (#1854) 2020-11-24 10:52:58 +01:00
Raphael Michel
e5d554a7b3 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-11-24 10:10:36 +01:00
Martin Gross
076aa097f6 Fix #1793 -- Remove hidden URLs from EventSettingsStore and avoid saving them (#1853) 2020-11-23 17:15:22 +01:00
Raphael Michel
97b9c1029a Avoid the word "simple" 2020-11-23 16:37:26 +01:00
Raphael Michel
2ebd040a7c Item form: Fancy radio buttons for has_Variations and admission 2020-11-23 15:25:48 +01:00
Raphael Michel
14a66ff80c Fix #1356 -- Allow to override config file settings with env vars 2020-11-23 12:24:08 +01:00
Raphael Michel
76c6bbc321 Merge pull request #1852 from pretix-translations/weblate-pretix-pretix
Co-authored-by: Jaakko Rinta-Filppula <jaakko@r-f.fi>
Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Mie Frydensbjerg <mif@aarhus.dk>
Co-authored-by: Maarten van den Berg <maartenberg1@gmail.com>
2020-11-22 14:12:57 +01:00
Raphael Michel
0272e44edd Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3838 of 3838 strings)

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

powered by weblate
2020-11-22 14:11:17 +01:00
Raphael Michel
99d2c40935 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3838 of 3838 strings)

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

powered by weblate
2020-11-22 14:11:17 +01:00
Maarten van den Berg
2720cf5ae1 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3807 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:17 +01:00
Maarten van den Berg
3e415c2654 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3807 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Mie Frydensbjerg
6d1ad45908 Translated on translate.pretix.eu (Danish)
Currently translated at 41.8% (1593 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Jaakko Rinta-Filppula
5514279868 Translated on translate.pretix.eu (Finnish)
Currently translated at 15.7% (599 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Mie Frydensbjerg
868aae0054 Translated on translate.pretix.eu (Danish)
Currently translated at 41.7% (1586 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Raphael Michel
55f89b2125 Translated on translate.pretix.eu (German (informal))
Currently translated at 99.9% (3806 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Raphael Michel
10e0e9e618 Translated on translate.pretix.eu (German)
Currently translated at 99.9% (3806 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Jaakko Rinta-Filppula
1119f90c02 Translated on translate.pretix.eu (Finnish)
Currently translated at 14.7% (560 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Jaakko Rinta-Filppula
35108c0e47 Translated on translate.pretix.eu (Finnish)
Currently translated at 13.5% (513 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Jaakko Rinta-Filppula
86b722015f Translated on translate.pretix.eu (Finnish)
Currently translated at 13.4% (509 of 3807 strings)

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

powered by weblate
2020-11-22 14:11:16 +01:00
Raphael Michel
54e9a03b9a Fix typo 2020-11-22 14:00:09 +01:00
Raphael Michel
c90365e908 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-11-22 13:49:52 +01:00
Raphael Michel
5c85c69b3d Brexit 2020-11-22 13:46:15 +01:00
Raphael Michel
6d9e1be844 Tax rules: Allow to block countries from making a purchase 2020-11-22 13:46:15 +01:00
Raphael Michel
168a6bae98 Bank transfer: Add optional text to pending payments 2020-11-20 14:10:20 +01:00
Raphael Michel
6c1fa8cf2d Bump version of markdown dependency 2020-11-19 17:17:39 +01:00
Raphael Michel
88be280445 Orders API: Add subevent_before parameter 2020-11-19 17:17:39 +01:00
Martin Gross
6aa3532ee6 Add effective presale_start and presale_end properties (#1851) 2020-11-19 15:04:19 +01:00
Raphael Michel
b8db58b978 Exporter API: Fix "This QueryDict instance is immutable" 2020-11-19 12:30:31 +01:00
Raphael Michel
5a95550075 Exporter API: Fix primary key fields 2020-11-19 11:28:21 +01:00
Raphael Michel
627f601bdb Widget: Fix waiting list for subevents 2020-11-19 11:10:24 +01:00
Raphael Michel
6c03e49090 Check-in list exporter: Fix bug if sorting is not set 2020-11-19 11:10:11 +01:00
Raphael Michel
0d0294a292 Fix test cases 2020-11-18 18:06:11 +01:00
Raphael Michel
d389a2aaa1 Make attendee_name accessible to secret generators 2020-11-18 18:05:57 +01:00
Raphael Michel
f51ec04e05 Pass request when manually editing gift cards 2020-11-18 17:22:24 +01:00
Raphael Michel
023f9eb6e7 Order list export: Optional columns for payment amounts 2020-11-17 22:11:22 +01:00
Raphael Michel
0bd1c3f3af Fix failing tests 2020-11-17 14:23:55 +01:00
Raphael Michel
821599dc1a Add advanced search to order list 2020-11-17 13:24:22 +01:00
Nics
9a65ad0abe Small typo in help text (#1850)
Fix a small typo in the help text of `allow_waitinglist`
2020-11-17 09:42:20 +01:00
Raphael Michel
12cb555917 Fix #1804 -- Admission time not lconed on event copy 2020-11-16 18:16:43 +01:00
Raphael Michel
87656cef4c Fix EventMixin.blocked_seats if no distance is set 2020-11-16 17:30:30 +01:00
Martin Gross
3a67203a0d Force a OCM seat-change if the subevent has changed 2020-11-16 15:00:18 +01:00
Raphael Michel
695a800811 Add seating views to POS device profile 2020-11-13 17:00:34 +01:00
pretix translation bot
e3c820b760 Translations update from Weblate (#1846)
Co-authored-by: Jaakko Rinta-Filppula <jaakko@r-f.fi>
Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Mie Frydensbjerg <mif@aarhus.dk>
2020-11-12 09:43:26 +01:00
Raphael Michel
c52bf0be8c Update seating plan schema to support float-sized areas 2020-11-12 09:26:25 +01:00
Raphael Michel
b287f870b1 Add new fields to seating plan schema 2020-11-12 09:02:51 +01:00
Raphael Michel
48f3a157bc Fix seating plan embedded in test suite 2020-11-11 14:40:36 +01:00
julia-luna
62a0dd2541 Add option to include prefix and invoice number in payment reference (#1848) 2020-11-11 14:27:43 +01:00
Raphael Michel
8c63f2159c Update to seating plan schema 2020-11-10 19:38:02 +01:00
Raphael Michel
e5a77dc482 Banktransfer: Show reference when switching payment method to bank transfer 2020-11-10 15:36:56 +01:00
Raphael Michel
bd81d7dced Fix inconsistent German translation 2020-11-10 15:29:19 +01:00
Raphael Michel
23c38a3742 Fix late-night bug in UserNotificationsDisableView 2020-11-10 15:09:09 +01:00
Raphael Michel
6c29fc0117 Remove a last mention of blacklist
Danke luto.
2020-11-10 13:14:49 +01:00
Raphael Michel
eae1fc9a81 Sendmail: Allow to send only to (un)approved 2020-11-10 11:37:38 +01:00
Raphael Michel
2c1195eaa1 Order list: Allow to filter for approved 2020-11-10 11:07:10 +01:00
Raphael Michel
f94e8e5bdc Fix checkboxes for variations with max_order=1 2020-11-10 10:04:15 +01:00
Raphael Michel
20ec388b03 Fix disabling notifications when logged in 2020-11-09 20:05:14 +01:00
Raphael Michel
02278660bc Fix issue in pdf report exporter 2020-11-06 17:38:48 +01:00
Raphael Michel
01b90ded36 Fix TypeError in cancellation self service 2020-11-06 16:33:19 +01:00
Raphael Michel
10b592a1c4 Document new webhooks 2020-11-06 11:50:53 +01:00
Raphael Michel
cfffcf2d1a Fix isort style issue 2020-11-06 11:50:01 +01:00
Raphael Michel
df83682d55 Add webhooks for changes to events and subevents 2020-11-06 11:46:54 +01:00
Raphael Michel
eeb3c1a960 Add support for bulk-webhooks 2020-11-06 11:46:06 +01:00
Raphael Michel
a7565342c0 Add test for appending slashes to URLs in the right situations 2020-11-06 10:49:24 +01:00
Raphael Michel
d03c5ce30c Fix multi-event export in backend 2020-11-06 10:49:11 +01:00
julia-luna
b51108ab22 Confirm disabling all notifications (#1845) 2020-11-05 18:40:53 +01:00
Raphael Michel
d08c811f3a Fix #1780 -- Trigger exports through API (#1839) 2020-11-05 18:30:12 +01:00
Raphael Michel
c757f3e4c7 Do not delete seats when deleting products 2020-11-05 16:37:44 +01:00
julia-luna
5962e4d4ab Add seating statistics in shared reports (#1844) 2020-11-05 12:20:25 +01:00
Raphael Michel
6fd2662956 Allow to change questions in canceled orders in backend 2020-11-05 09:29:05 +01:00
Raphael Michel
259d2cdb27 Fix isort issue 2020-11-04 17:42:56 +01:00
Raphael Michel
04e9c8a226 Copy ItemBundle when cloning events 2020-11-04 12:30:38 +01:00
Raphael Michel
78798ff382 Merge pull request #1843 from pretix/docker-components 2020-11-04 10:23:17 +01:00
Raphael Michel
be1926ff21 Add documentation on new docker features 2020-11-04 09:52:13 +01:00
Raphael Michel
6af5b3fd5e Add option to skip auto migration 2020-11-04 09:48:14 +01:00
Raphael Michel
8989723145 Install django-extensions and ipython to make docker container easier to debug 2020-11-04 09:48:00 +01:00
Raphael Michel
e980b2c255 Allow to run nginx+gunicorn in Docker container 2020-11-04 09:47:48 +01:00
Raphael Michel
cb0023dc3c Update docker container to Python 3.8 2020-11-04 09:46:08 +01:00
Raphael Michel
b4c18c6ea6 Fix inverted logic 2020-11-02 17:58:44 +01:00
Raphael Michel
e07cca9148 External refunds: Processing should not affect order's state if order is canceled 2020-11-02 17:08:53 +01:00
Raphael Michel
031ee647ab External refunds: automatically mark as done if they exactly fix an overpaid order 2020-11-02 17:06:47 +01:00
Raphael Michel
6ca6f9437f Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-11-02 17:00:13 +01:00
Raphael Michel
07ff523ea3 Don't mention subevents in user-facing strings 2020-11-02 16:59:33 +01:00
Raphael Michel
92df47d0c7 Merge pull request #1840 from pretix-translations/weblate-pretix-pretix 2020-11-02 14:28:49 +01:00
Raphael Michel
717c905d16 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3807 of 3807 strings)

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

powered by weblate
2020-11-02 14:27:03 +01:00
Raphael Michel
e922bd7376 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3807 of 3807 strings)

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

powered by weblate
2020-11-02 14:27:02 +01:00
Raphael Michel
a48d844456 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-11-02 13:46:32 +01:00
Raphael Michel
48119038b4 Merge pull request #1836 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-11-02 13:45:57 +01:00
Raphael Michel
598f0b316e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3797 of 3797 strings)

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

powered by weblate
2020-11-02 13:45:45 +01:00
Raphael Michel
7df503fb4f Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3797 of 3797 strings)

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

powered by weblate
2020-11-02 13:45:44 +01:00
Jaakko Rinta-Filppula
4c84cf7b37 Translated on translate.pretix.eu (Finnish)
Currently translated at 11.7% (443 of 3797 strings)

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

powered by weblate
2020-11-02 12:47:38 +01:00
Martin Gross
f969db69cb Allow Refunds for SEPA Debit (#1838) 2020-11-02 12:47:33 +01:00
Raphael Michel
fb92676aee Fix test suite failures 2020-11-01 15:43:57 +01:00
Raphael Michel
6052895ada Declare pretix.plugins.reports a core module 2020-11-01 15:01:24 +01:00
Raphael Michel
7a98f3fa89 Own column for unapproved orders in order overview 2020-11-01 15:00:54 +01:00
Raphael Michel
da149682aa Improve load behavior of ajaxpending.js 2020-10-31 16:33:45 +01:00
Raphael Michel
ba4eff5545 Fix cart ID handling issue in widget if cart/add takes longer than one request 2020-10-31 16:22:38 +01:00
Raphael Michel
32c08d431f Improve responsive design on "small" breakpoint 2020-10-31 16:22:26 +01:00
Raphael Michel
ecd914f44d Fix typo 2020-10-30 22:39:12 +01:00
Raphael Michel
f6dc90fb28 Show message and cart after tax rate has changed 2020-10-30 22:33:44 +01:00
Raphael Michel
4093c1d909 Remove buy from a string to make it more compatible for free events 2020-10-30 22:06:14 +01:00
Raphael Michel
9da14dfebe Widget API: Use sales channel of request 2020-10-30 22:05:55 +01:00
Raphael Michel
a941378b80 Allow to book users to a seat even if self-seating is now available 2020-10-30 16:49:32 +01:00
Raphael Michel
9202aca26a Allow to keep a few per ticket when cancelling an event 2020-10-30 15:49:34 +01:00
Raphael Michel
b841878dcb Ensure to return a 404 if an appending slash is missing 2020-10-30 14:40:55 +01:00
Raphael Michel
2cf6a4a6ab Add previously uncommitted tests 2020-10-29 18:47:27 +01:00
Raphael Michel
8759155357 Sendmail: Keep uploaded attachment when preview is used 2020-10-29 18:46:57 +01:00
Raphael Michel
1fe4d1a8ca Fix inconsistent naming of a scheme 2020-10-29 17:47:53 +01:00
Raphael Michel
73e0937d80 Merge pull request #1835 from pretix-translations/weblate-pretix-pretix 2020-10-29 11:22:45 +01:00
David Vaz
151d5c4f2b Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 100.0% (3797 of 3797 strings)

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

powered by weblate
2020-10-29 11:22:34 +01:00
David Vaz
8486f66e69 Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 100.0% (3797 of 3797 strings)

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

powered by weblate
2020-10-29 11:22:34 +01:00
Jaakko Rinta-Filppula
9bb8f7b429 Translated on translate.pretix.eu (Finnish)
Currently translated at 11.2% (425 of 3797 strings)

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

powered by weblate
2020-10-29 11:22:34 +01:00
David Vaz
53ce1a53c6 Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 100.0% (128 of 128 strings)

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

powered by weblate
2020-10-29 11:22:34 +01:00
Miguel Magalhães
ce61c8a23a Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 100.0% (3797 of 3797 strings)

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

powered by weblate
2020-10-29 11:22:34 +01:00
David Vaz
13f825ec1b Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 100.0% (3797 of 3797 strings)

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

powered by weblate
2020-10-29 11:22:34 +01:00
Raphael Michel
4ff4402a5f Allow to cancel subevents by date range 2020-10-29 10:08:37 +01:00
Raphael Michel
b4964b1460 Sendmail: Allow to notify a date range of subevents 2020-10-29 09:52:02 +01:00
Raphael Michel
710aaa5f1c Add icons to order status in backend 2020-10-29 09:17:00 +01:00
Raphael Michel
ed12fd3cd5 Add lang_info for pt-pt 2020-10-28 18:17:56 +01:00
Raphael Michel
ec7be3bd07 Add flag to PT input fields 2020-10-28 14:30:39 +01:00
Raphael Michel
95aa7b7619 Fix selection of the wrong Portoguese 2020-10-28 14:23:50 +01:00
Raphael Michel
f9d1dc7181 Increase retry interval of emails 2020-10-27 09:23:59 +01:00
Raphael Michel
ad094bcfc0 Remove pt-PT from incubating languages 2020-10-26 18:02:11 +01:00
Raphael Michel
2b1d9bc039 Merge pull request #1834 from pretix-translations/weblate-pretix-pretix 2020-10-26 16:41:09 +01:00
Miguel Magalhães
762d815cf5 Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 82.9% (3147 of 3797 strings)

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

powered by weblate
2020-10-26 15:14:05 +01:00
tlm06
6a71b9bf19 Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 70.3% (2671 of 3797 strings)

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

powered by weblate
2020-10-26 11:00:20 +01:00
David Vaz
d2617ca104 Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 69.9% (2656 of 3797 strings)

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

powered by weblate
2020-10-26 11:00:20 +01:00
David Vaz
a3573125df Translated on translate.pretix.eu (Portuguese (Portugal))
Currently translated at 69.1% (2624 of 3797 strings)

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

powered by weblate
2020-10-26 11:00:20 +01:00
Raphael Michel
565a65f780 Clarify MANIFEST.in 2020-10-26 10:47:31 +01:00
Raphael Michel
9543d89014 Fix packaging bugs 2020-10-26 10:35:19 +01:00
julia-luna
e61288ba67 Add option to send emails to attendees (#1833) 2020-10-26 10:31:45 +01:00
Raphael Michel
58af025fd8 Bump to 3.13.0.dev0 2020-10-26 10:27:39 +01:00
191 changed files with 67164 additions and 36384 deletions

View File

@@ -1,4 +1,4 @@
FROM python:3.6
FROM python:3.8
RUN apt-get update && \
apt-get install -y --no-install-recommends \
@@ -30,7 +30,8 @@ RUN apt-get update && \
mkdir /data && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static
mkdir /static && \
mkdir /etc/supervisord
ENV LC_ALL=C.UTF-8 \
DJANGO_SETTINGS_MODULE=production_settings
@@ -47,12 +48,13 @@ RUN pip3 install -U \
-r requirements.txt \
-r requirements/memcached.txt \
-r requirements/mysql.txt \
-r requirements/redis.txt \
gunicorn && \
gunicorn django-extensions ipython && \
rm -rf ~/.cache/pip
COPY deployment/docker/pretix.bash /usr/local/bin/pretix
COPY deployment/docker/supervisord.conf /etc/supervisord.conf
COPY deployment/docker/supervisord /etc/supervisord
COPY deployment/docker/supervisord.all.conf /etc/supervisord.all.conf
COPY deployment/docker/supervisord.web.conf /etc/supervisord.web.conf
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
COPY src /pretix/src

View File

@@ -5,6 +5,8 @@ export DATA_DIR=/data/
export HOME=/pretix
export NUM_WORKERS=$((2 * $(nproc --all)))
AUTOMIGRATE=${AUTOMIGRATE:-yes}
if [ ! -d /data/logs ]; then
mkdir /data/logs;
fi
@@ -16,10 +18,16 @@ if [ "$1" == "cron" ]; then
exec python3 -m pretix runperiodic
fi
python3 -m pretix migrate --noinput
if [ "$AUTOMIGRATE" != "skip" ]; then
python3 -m pretix migrate --noinput
fi
if [ "$1" == "all" ]; then
exec sudo -E /usr/bin/supervisord -n -c /etc/supervisord.conf
exec sudo -E /usr/bin/supervisord -n -c /etc/supervisord.all.conf
fi
if [ "$1" == "web" ]; then
exec sudo -E /usr/bin/supervisord -n -c /etc/supervisord.web.conf
fi
if [ "$1" == "webworker" ]; then
@@ -37,10 +45,6 @@ if [ "$1" == "taskworker" ]; then
exec celery -A pretix.celery_app worker -l info "$@"
fi
if [ "$1" == "shell" ]; then
exec python3 -m pretix shell
fi
if [ "$1" == "upgrade" ]; then
exec python3 -m pretix updatestyles
fi

View File

@@ -0,0 +1,2 @@
[include]
files = /etc/supervisord/*.conf

View File

@@ -1,44 +0,0 @@
[unix_http_server]
file=/tmp/supervisor.sock
[supervisord]
logfile=/tmp/supervisord.log
logfile_maxbytes=50MB
logfile_backups=10
loglevel=info
pidfile=/tmp/supervisord.pid
nodaemon=false
minfds=1024
minprocs=200
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///tmp/supervisor.sock
[program:pretixweb]
command=/usr/local/bin/pretix webworker
autostart=true
autorestart=true
priority=5
user=pretixuser
environment=HOME=/pretix
[program:pretixtask]
command=/usr/local/bin/pretix taskworker
autostart=true
autorestart=true
priority=5
user=pretixuser
[program:nginx]
command=/usr/sbin/nginx
autostart=true
autorestart=true
priority=10
stdout_events_enabled=true
stderr_events_enabled=true
[include]
files = /etc/supervisord-*.conf

View File

@@ -0,0 +1,2 @@
[include]
files = /etc/supervisord/base.conf /etc/supervisord/nginx.conf /etc/supervisord/pretixweb.conf

View File

@@ -0,0 +1,18 @@
[unix_http_server]
file=/tmp/supervisor.sock
[supervisord]
logfile=/tmp/supervisord.log
logfile_maxbytes=50MB
logfile_backups=10
loglevel=info
pidfile=/tmp/supervisord.pid
nodaemon=false
minfds=1024
minprocs=200
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///tmp/supervisor.sock

View File

@@ -0,0 +1,7 @@
[program:nginx]
command=/usr/sbin/nginx
autostart=true
autorestart=true
priority=10
stdout_events_enabled=true
stderr_events_enabled=true

View File

@@ -0,0 +1,6 @@
[program:pretixtask]
command=/usr/local/bin/pretix taskworker
autostart=true
autorestart=true
priority=5
user=pretixuser

View File

@@ -0,0 +1,7 @@
[program:pretixweb]
command=/usr/local/bin/pretix webworker
autostart=true
autorestart=true
priority=5
user=pretixuser
environment=HOME=/pretix

View File

@@ -23,6 +23,14 @@ The config file may contain the following sections (all settings are optional an
default values). We suggest that you start from the examples given in one of the
installation tutorials.
.. note::
The configuration file is the recommended way to configure pretix. However, you can
also set them through environment variables. In this case, the syntax is
``PRETIX_SECTION_CONFIG``. For example, to configure the setting ``password_reset``
from the ``[pretix]`` section, set ``PRETIX_PRETIX_PASSWORD_RESET=off`` in your
environment.
pretix settings
---------------

View File

@@ -284,6 +284,24 @@ Then, go to that directory and build the image::
You can now use that image ``mypretix`` instead of ``pretix/standalone`` in your service file (see above). Be sure
to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an update.
Scaling up
----------
If you need to scale to multiple machines, please first read our :ref:`scaling guide <scaling>`.
If you run the official docker container on multiple machines, it is recommended to set the environment
variable ``AUTOMIGRATE=skip`` on all containers and run ``docker exec -it pretix.service pretix migrate``
on one machine after each upgrade manually, otherwise multiple containers might try to upgrade the
database schema at the same time.
To run only the ``pretix-web`` component of pretix as well as a nginx server serving static files, you
can invoke the container with ``docker run … pretix/standalone:stable web`` (instead of ``all``).
To run only ``pretix-worker``, you can run ``docker run … pretix/standalone:stable taskworker``. You can
also pass arguments to limit the worker to specific queues or to change the number of concurrent task
workers, e.g. ``docker run … taskworker -Q notifications --concurrency 32``.
.. _Docker: https://docs.docker.com/engine/installation/linux/debian/
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/

View File

@@ -0,0 +1,215 @@
.. spelling:: checkin
Data exporters
==============
pretix and it's plugins include a number of data exporters that allow you to bulk download various data from pretix in
different formats. This page shows you how to use these exporters through the API.
.. versionchanged:: 3.13
This feature has been added to the API.
.. warning::
While we consider the methods listed on this page to be a stable API, the availability and specific input field
requirements of individual exporters is **not considered a stable API**. Specific exporters and their input parameters
may change at any time without warning.
Listing available exporters
---------------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exporters/
Returns a list of all exporters available for a given event. You will receive a list of export methods as well as their
supported input fields. Note that the exact type and validation requirements of the input fields are not given in the
response, and you might need to look into the pretix web interface to figure out the exact input required.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/exporters/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"identifier": "orderlist",
"verbose_name": "Order data",
"input_parameters": [
{
"name": "_format",
"required": true,
"choices": [
"xlsx",
"orders:default",
"orders:excel",
"orders:semicolon",
"positions:default",
"positions:excel",
"positions:semicolon",
"fees:default",
"fees:excel",
"fees:semicolon"
]
},
{
"name": "paid_only",
"required": false
}
]
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/exporters/
Returns a list of all cross-event exporters available for a given organizer. You will receive a list of export methods as well as their
supported input fields. Note that the exact type and validation requirements of the input fields are not given in the
response, and you might need to look into the pretix web interface to figure out the exact input required.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/exporters/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"identifier": "orderlist",
"verbose_name": "Order data",
"input_parameters": [
{
"name": "events",
"required": true
},
{
"name": "_format",
"required": true,
"choices": [
"xlsx",
"orders:default",
"orders:excel",
"orders:semicolon",
"positions:default",
"positions:excel",
"positions:semicolon",
"fees:default",
"fees:excel",
"fees:semicolon"
]
},
{
"name": "paid_only",
"required": false
}
]
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Running an export
-----------------
Since exports often include large data sets, they might take longer than the duration of an HTTP request. Therefore,
creating an export is a two-step process. First you need to start an export task with one of the following to API
endpoints:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exporters/(identifier)/run/
Starts an export task. If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
The body points you to the download URL of the result.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/exporters/orderlist/run/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"_format": "xlsx"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderlist/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param identifier: The ``identifier`` field of the exporter to run
:statuscode 202: no error
:statuscode 400: Invalid input options
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/exporters/(identifier)/run/
The endpoint for organizer-level exports works just like event-level exports (see above).
Downloading the result
----------------------
When starting an export, you receive a ``url`` for downloading the result. Running a ``GET`` request on that result will
yield one of the following status codes:
* ``200 OK`` The export succeeded. The body will be your resulting file. Might be large!
* ``409 Conflict`` Your export is still running. The body will be JSON with the structure ``{"status": "running", "percentage": 40}``. ``percentage`` can be ``null`` if it is not known and ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
* ``410 Gone`` Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
* ``404 Not Found`` The export does not exist / is expired.
.. warning::
Running exports puts a lot of stress on the system, we kindly ask you not to run more than two exports at the same time.

View File

@@ -27,5 +27,6 @@ Resources and endpoints
devices
webhooks
seatingplans
exporters
billing_invoices
billing_var

View File

@@ -163,6 +163,10 @@ last_modified datetime Last modificati
The ``exclude`` and ``subevent_after`` query parameter has been added.
.. versionchanged:: 3.13
The ``subevent_before`` query parameter has been added.
.. _order-position-resource:
@@ -490,7 +494,8 @@ List of all orders
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
you will not notice it using this method.
:query datetime created_since: Only return orders that have been created since the given date.
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date.
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch

View File

@@ -31,8 +31,10 @@ action_types list of strings A list of actio
The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.order.placed``
* ``pretix.event.order.placed.require_approval``
* ``pretix.event.order.paid``
* ``pretix.event.order.canceled``
* ``pretix.event.order.reactivated``
* ``pretix.event.order.expired``
* ``pretix.event.order.modified``
* ``pretix.event.order.contact.changed``
@@ -42,6 +44,12 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.order.denied``
* ``pretix.event.checkin``
* ``pretix.event.checkin.reverted``
* ``pretix.event.added``
* ``pretix.event.changed``
* ``pretix.event.deleted``
* ``pretix.subevent.added``
* ``pretix.subevent.changed``
* ``pretix.subevent.deleted``
Installed plugins might register more valid values.

View File

@@ -58,7 +58,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_position_buttons, subevent_forms,
item_formsets, order_search_filter_q
item_formsets, order_search_filter_q, order_search_forms
.. automodule:: pretix.base.signals
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events

View File

@@ -1 +1 @@
__version__ = "3.12.1"
__version__ = "3.13.1"

View File

@@ -108,6 +108,10 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:event.settings'),
('GET', 'plugins:pretix_seating:event.event'),
('GET', 'plugins:pretix_seating:event.event.subevent'),
('GET', 'plugins:pretix_seating:event.plan'),
('GET', 'plugins:pretix_seating:selection.simple'),
)

View File

@@ -0,0 +1,127 @@
from django import forms
from django.http import QueryDict
from rest_framework import serializers
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):
self.form_field = kwargs.pop('form_field')
super().__init__(*args, **kwargs)
def to_representation(self, value):
return self.form_field.widget.format_value(value)
def to_internal_value(self, data):
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
d = self.form_field.clean(d)
return d
simple_mappings = (
(forms.DateField, serializers.DateField, tuple()),
(forms.TimeField, serializers.TimeField, tuple()),
(forms.SplitDateTimeField, serializers.DateTimeField, tuple()),
(forms.DateTimeField, serializers.DateTimeField, tuple()),
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
(forms.FloatField, serializers.FloatField, tuple()),
(forms.IntegerField, serializers.IntegerField, tuple()),
(forms.EmailField, serializers.EmailField, tuple()),
(forms.UUIDField, serializers.UUIDField, tuple()),
(forms.URLField, serializers.URLField, tuple()),
(forms.NullBooleanField, serializers.NullBooleanField, tuple()),
(forms.BooleanField, serializers.BooleanField, tuple()),
)
class SerializerDescriptionField(serializers.Field):
def to_representation(self, value):
fields = []
for k, v in value.fields.items():
d = {
'name': k,
'required': v.required,
}
if isinstance(v, serializers.ChoiceField):
d['choices'] = list(v.choices.keys())
fields.append(d)
return fields
class ExporterSerializer(serializers.Serializer):
identifier = serializers.CharField()
verbose_name = serializers.CharField()
input_parameters = SerializerDescriptionField(source='_serializer')
class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def to_representation(self, value):
if isinstance(value, int):
return value
return super().to_representation(value)
class JobRunSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
ex = kwargs.pop('exporter')
events = kwargs.pop('events', None)
super().__init__(*args, **kwargs)
if events is not None:
self.fields["events"] = serializers.SlugRelatedField(
queryset=events,
required=True,
allow_empty=False,
slug_field='slug',
many=True
)
for k, v in ex.export_form_fields.items():
for m_from, m_to, m_kwargs in simple_mappings:
if isinstance(v, m_from):
self.fields[k] = m_to(
required=v.required,
allow_null=not v.required,
validators=v.validators,
**{kwarg: getattr(v, kwargs, None) for kwarg in m_kwargs}
)
break
if isinstance(v, forms.ModelMultipleChoiceField):
self.fields[k] = PrimaryKeyRelatedField(
queryset=v.queryset,
required=v.required,
allow_empty=not v.required,
validators=v.validators,
many=True
)
elif isinstance(v, forms.ModelChoiceField):
self.fields[k] = PrimaryKeyRelatedField(
queryset=v.queryset,
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
elif isinstance(v, forms.MultipleChoiceField):
self.fields[k] = serializers.MultipleChoiceField(
choices=v.choices,
required=v.required,
allow_empty=not v.required,
validators=v.validators,
)
elif isinstance(v, forms.ChoiceField):
self.fields[k] = serializers.ChoiceField(
choices=v.choices,
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
else:
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
def to_internal_value(self, data):
if isinstance(data, QueryDict):
data = data.copy()
for k, v in self.fields.items():
if isinstance(v, serializers.ManyRelatedField) and k not in data:
data[k] = []
data = super().to_internal_value(data)
return data

View File

@@ -7,8 +7,8 @@ from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, device, event, item, oauth, order, organizer, user, version,
voucher, waitinglist, webhooks,
checkin, device, event, exporters, item, oauth, order, organizer, user,
version, voucher, waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -22,6 +22,7 @@ orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
orga_router.register(r'teams', organizer.TeamViewSet)
orga_router.register(r'devices', organizer.DeviceViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet)
@@ -44,6 +45,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet)
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
checkinlist_router = routers.DefaultRouter()
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')

View File

@@ -0,0 +1,154 @@
from datetime import timedelta
from celery.result import AsyncResult
from django.conf import settings
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.timezone import now
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.reverse import reverse
from pretix.api.serializers.exporters import (
ExporterSerializer, JobRunSerializer,
)
from pretix.base.models import CachedFile, Device, TeamAPIToken
from pretix.base.services.export import export, multiexport
from pretix.base.signals import (
register_data_exporters, register_multievent_data_exporters,
)
from pretix.helpers.http import ChunkBasedFileResponse
class ExportersMixin:
def list(self, request, *args, **kwargs):
res = ExporterSerializer(self.exporters, many=True)
return Response({
"count": len(self.exporters),
"next": None,
"previous": None,
"results": res.data
})
def get_object(self):
instances = [e for e in self.exporters if e.identifier == self.kwargs.get('pk')]
if not instances:
raise Http404()
return instances[0]
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = ExporterSerializer(instance)
return Response(serializer.data)
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
def download(self, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
if cf.file:
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
return resp
elif not settings.HAS_CELERY:
return Response(
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
status=status.HTTP_410_GONE
)
res = AsyncResult(kwargs['asyncid'])
if res.failed():
if isinstance(res.info, dict) and res.info['exc_type'] == 'ExportError':
msg = res.info['exc_message']
else:
msg = 'Internal error'
return Response(
{'status': 'failed', 'message': msg},
status=status.HTTP_410_GONE
)
return Response(
{
'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting',
'percentage': res.result.get('value', None) if res.result else None,
},
status=status.HTTP_409_CONFLICT
)
@action(detail=True, methods=['POST'])
def run(self, *args, **kwargs):
instance = self.get_object()
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
serializer.is_valid(raise_exception=True)
cf = CachedFile(web_download=False)
cf.date = now()
cf.expires = now() + timedelta(hours=24)
cf.save()
d = serializer.data
for k, v in d.items():
if isinstance(v, set):
d[k] = list(v)
async_result = self.do_export(cf, instance, d)
url_kwargs = {
'asyncid': str(async_result.id),
'cfid': str(cf.id),
}
url_kwargs.update(self.kwargs)
return Response({
'download': reverse('api-v1:exporters-download', kwargs=url_kwargs, request=self.request)
}, status=status.HTTP_202_ACCEPTED)
class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
permission = 'can_view_orders'
def get_serializer_kwargs(self):
return {}
@cached_property
def exporters(self):
exporters = []
responses = register_data_exporters.send(self.request.event)
for ex in sorted([response(self.request.event) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
return exporters
def do_export(self, cf, instance, data):
return export.apply_async(args=(self.request.event.id, str(cf.id), instance.identifier, data))
class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
permission = None
@cached_property
def exporters(self):
exporters = []
events = (self.request.auth or self.request.user).get_events_with_permission('can_view_orders', request=self.request).filter(
organizer=self.request.organizer
)
responses = register_multievent_data_exporters.send(self.request.organizer)
for ex in sorted([response(events) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex, events=events)
exporters.append(ex)
return exporters
def get_serializer_kwargs(self):
return {
'events': self.request.auth.get_events_with_permission('can_view_orders', request=self.request).filter(
organizer=self.request.organizer
)
}
def do_export(self, cf, instance, data):
return multiexport.apply_async(kwargs={
'organizer': self.request.organizer.id,
'user': self.request.user.id if self.request.user.is_authenticated else None,
'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None,
'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None,
'fileid': str(cf.id),
'provider': instance.identifier,
'form_data': data
})

View File

@@ -33,7 +33,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
TeamAPIToken, generate_secret,
TaxRule, TeamAPIToken, generate_secret,
)
from pretix.base.models.orders import RevokedTicketSecret
from pretix.base.payment import PaymentException
@@ -65,6 +65,7 @@ with scopes_disabled():
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
subevent_after = django_filters.IsoDateTimeFilter(method='subevent_after_qs')
subevent_before = django_filters.IsoDateTimeFilter(method='subevent_before_qs')
search = django_filters.CharFilter(method='search_qs')
class Meta:
@@ -84,6 +85,19 @@ with scopes_disabled():
).filter(has_se_after=True)
return qs
def subevent_before_qs(self, qs, name, value):
qs = qs.annotate(
has_se_before=Exists(
OrderPosition.all.filter(
subevent_id__in=SubEvent.objects.filter(
Q(date_from__lt=value), event=OuterRef(OuterRef('event_id'))
).values_list('id'),
order_id=OuterRef('pk'),
)
)
).filter(has_se_before=True)
return qs
def search_qs(self, qs, name, value):
u = value
if "-" in value:
@@ -547,7 +561,10 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer = OrderCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic():
self.perform_create(serializer)
try:
self.perform_create(serializer)
except TaxRule.SaleNotAllowed:
raise ValidationError(_('One of the selected products is not available in the selected country.'))
send_mail = serializer._send_mail
order = serializer.instance
if not order.pk:

View File

@@ -7,7 +7,7 @@ import requests
from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import scope, scopes_disabled
from requests import RequestException
@@ -97,6 +97,67 @@ class ParametrizedOrderWebhookEvent(WebhookEvent):
}
class ParametrizedEventWebhookEvent(WebhookEvent):
def __init__(self, action_type, verbose_name):
self._action_type = action_type
self._verbose_name = verbose_name
super().__init__()
@property
def action_type(self):
return self._action_type
@property
def verbose_name(self):
return self._verbose_name
def build_payload(self, logentry: LogEntry):
if logentry.action_type == 'pretix.event.deleted':
organizer = logentry.content_object
return {
'notification_id': logentry.pk,
'organizer': organizer.slug,
'event': logentry.parsed_data.get('slug'),
'action': logentry.action_type,
}
event = logentry.content_object
if not event:
return None
return {
'notification_id': logentry.pk,
'organizer': event.organizer.slug,
'event': event.slug,
'action': logentry.action_type,
}
class ParametrizedSubEventWebhookEvent(WebhookEvent):
def __init__(self, action_type, verbose_name):
self._action_type = action_type
self._verbose_name = verbose_name
super().__init__()
@property
def action_type(self):
return self._action_type
@property
def verbose_name(self):
return self._verbose_name
def build_payload(self, logentry: LogEntry):
# do not use content_object, this is also called in deletion
return {
'notification_id': logentry.pk,
'organizer': logentry.event.organizer.slug,
'event': logentry.event.slug,
'subevent': logentry.object_id,
'action': logentry.action_type,
}
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
def build_payload(self, logentry: LogEntry):
@@ -169,44 +230,69 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.checkin.reverted',
_('Ticket check-in reverted'),
),
ParametrizedEventWebhookEvent(
'pretix.event.added',
_('Event created'),
),
ParametrizedEventWebhookEvent(
'pretix.event.changed',
_('Event details changed'),
),
ParametrizedEventWebhookEvent(
'pretix.event.deleted',
_('Event details changed'),
),
ParametrizedSubEventWebhookEvent(
'pretix.subevent.added',
pgettext_lazy('subevent', 'Event series date added'),
),
ParametrizedSubEventWebhookEvent(
'pretix.subevent.changed',
pgettext_lazy('subevent', 'Event series date changed'),
),
ParametrizedSubEventWebhookEvent(
'pretix.subevent.deleted',
pgettext_lazy('subevent', 'Event series date deleted'),
),
)
@app.task(base=TransactionAwareTask, acks_late=True)
def notify_webhooks(logentry_id: int):
logentry = LogEntry.all.select_related('event', 'event__organizer').get(id=logentry_id)
def notify_webhooks(logentry_ids: list):
if not isinstance(logentry_ids, list):
logentry_ids = [logentry_ids]
qs = LogEntry.all.select_related('event', 'event__organizer').filter(id__in=logentry_ids)
_org, _at, webhooks = None, None, None
for logentry in qs:
if not logentry.organizer:
break # We need to know the organizer
if not logentry.organizer:
return # We need to know the organizer
notification_type = logentry.webhook_type
types = get_all_webhook_events()
notification_type = None
typepath = logentry.action_type
while not notification_type and '.' in typepath:
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
if not notification_type:
break # Ignore, no webhooks for this event type
if not notification_type:
return # Ignore, no webhooks for this event type
if _org != logentry.organizer or _at != logentry.action_type or webhooks is None:
_org = logentry.organizer
_at = logentry.action_type
# All webhooks that registered for this notification
event_listener = WebHookEventListener.objects.filter(
webhook=OuterRef('pk'),
action_type=notification_type.action_type
)
# All webhooks that registered for this notification
event_listener = WebHookEventListener.objects.filter(
webhook=OuterRef('pk'),
action_type=notification_type.action_type
)
webhooks = WebHook.objects.annotate(has_el=Exists(event_listener)).filter(
organizer=logentry.organizer,
has_el=True,
enabled=True
)
if logentry.event_id:
webhooks = webhooks.filter(
Q(all_events=True) | Q(limit_events__pk=logentry.event_id)
)
webhooks = WebHook.objects.annotate(has_el=Exists(event_listener)).filter(
organizer=logentry.organizer,
has_el=True,
enabled=True
)
if logentry.event_id:
webhooks = webhooks.filter(
Q(all_events=True) | Q(limit_events__pk=logentry.event_id)
)
for wh in webhooks:
send_webhook.apply_async(args=(logentry_id, notification_type.action_type, wh.pk))
for wh in webhooks:
send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk))
@app.task(base=ProfiledTask, bind=True, max_retries=9, acks_late=True)
@@ -250,7 +336,7 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
raise self.retry(countdown=2 ** (self.request.retries * 2))
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
@@ -262,6 +348,6 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2))
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
except MaxRetriesExceededError:
pass

View File

@@ -73,8 +73,8 @@ banlist = [
"wtf"
]
blacklist_regex = re.compile('(' + '|'.join(banlist) + ')')
banlist_regex = re.compile('(' + '|'.join(banlist) + ')')
def banned(string):
return bool(blacklist_regex.search(string.lower()))
return bool(banlist_regex.search(string.lower()))

View File

@@ -41,7 +41,7 @@ class MailExporter(BaseExporter):
initial=[Order.STATUS_PENDING, Order.STATUS_PAID],
choices=Order.STATUS_CHOICE,
widget=forms.CheckboxSelectMultiple,
required=False
required=True
)),
]
)

View File

@@ -53,9 +53,23 @@ class OrderListExporter(MultiSheetListExporter):
initial=True,
required=False
)),
('include_payment_amounts',
forms.BooleanField(
label=_('Include payment amounts'),
initial=False,
required=False
)),
]
)
def _get_all_payment_methods(self, qs):
pps = dict(get_all_payment_providers())
return sorted([(pp, pps[pp]) for pp in set(
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
'provider', flat=True
).distinct()
)], key=lambda pp: pp[0])
def _get_all_tax_rates(self, qs):
tax_rates = set(
a for a
@@ -150,6 +164,10 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Comment'))
headers.append(_('Positions'))
headers.append(_('Payment providers'))
if form_data.get('include_payment_amounts'):
payment_methods = self._get_all_payment_methods(qs)
for id, vn in payment_methods:
headers.append(_('Paid by {method}').format(method=vn))
yield headers
@@ -163,6 +181,23 @@ class OrderListExporter(MultiSheetListExporter):
taxsum=Sum('tax_value'), grosssum=Sum('value')
)
}
if form_data.get('include_payment_amounts'):
payment_sum_cache = {
(o['order__id'], o['provider']): o['grosssum'] for o in
OrderPayment.objects.values('provider', 'order__id').order_by().filter(
state__in=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED]
).annotate(
grosssum=Sum('amount')
)
}
refund_sum_cache = {
(o['order__id'], o['provider']): o['grosssum'] for o in
OrderRefund.objects.values('provider', 'order__id').order_by().filter(
state__in=[OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT]
).annotate(
grosssum=Sum('amount')
)
}
sum_cache = {
(o['order__id'], o['tax_rate']): o for o in
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
@@ -234,6 +269,14 @@ class OrderListExporter(MultiSheetListExporter):
str(self.providers.get(p, p)) for p in sorted(set((order.payment_providers or '').split(',')))
if p and p != 'free'
]))
if form_data.get('include_payment_amounts'):
payment_methods = self._get_all_payment_methods(qs)
for id, vn in payment_methods:
row.append(
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
refund_sum_cache.get((order.id, id), Decimal('0.00'))
)
yield row
def iterate_fees(self, form_data: dict):

View File

@@ -34,7 +34,9 @@ from pretix.base.forms.widgets import (
)
from pretix.base.i18n import language
from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.models.tax import (
EU_COUNTRIES, cc_to_vat_prefix, is_eu_country,
)
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
@@ -648,7 +650,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['state'].widget.is_required = True
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
if cc and cc not in EU_COUNTRIES and fprefix + 'vat_id' in self.data:
if cc and not is_eu_country(cc) and fprefix + 'vat_id' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'vat_id']
@@ -698,7 +700,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not data.get('is_business'):
data['company'] = ''
data['vat_id'] = ''
if data.get('is_business') and not data.get('country') in EU_COUNTRIES:
if data.get('is_business') and not is_eu_country(data.get('country')):
data['vat_id'] = ''
if self.event.settings.invoice_address_required:
if data.get('is_business') and not data.get('company'):
@@ -722,7 +724,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.cleaned_data['country'] = ''
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
elif self.validate_vat_id and data.get('is_business') and is_eu_country(data.get('country')) and data.get('vat_id'):
if data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
raise ValidationError(_('Your VAT ID does not match the selected country.'))
try:

View File

@@ -3,7 +3,8 @@ from urllib.parse import urlsplit
import pytz
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from django.http import Http404, HttpRequest, HttpResponse
from django.middleware.common import CommonMiddleware
from django.urls import get_script_prefix
from django.utils import timezone, translation
from django.utils.cache import patch_vary_headers
@@ -252,3 +253,15 @@ class SecurityMiddleware(MiddlewareMixin):
del resp['Content-Security-Policy']
return resp
class CustomCommonMiddleware(CommonMiddleware):
def get_full_path_with_slash(self, request):
"""
Raise an error regardless of DEBUG mode when in POST, PUT, or PATCH.
"""
new_path = super().get_full_path_with_slash(request)
if request.method in ('POST', 'PUT', 'PATCH'):
raise Http404('Please append a / at the end of the URL')
return new_path

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.9 on 2020-11-23 15:51
from django.db import migrations
def remove_old_settings(app, schema_editor):
EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
EventSettingsStore.objects.filter(key__startswith='payment_', key__endswith='__hidden_url').delete()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0169_checkinlist_gates'),
]
operations = [
migrations.RunPython(remove_old_settings, migrations.RunPython.noop)
]

View File

@@ -1,4 +1,4 @@
# Generated by Django 3.0.11 on 2020-12-22 10:28
# Generated by Django 3.0.11 on 2020-12-22 10:30
from django.db import migrations
@@ -6,7 +6,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0169_checkinlist_gates'),
('pretixbase', '0170_remove_hidden_urls'),
('pretixbase', '0162b_auto_20201218_1810'),
]

View File

@@ -51,9 +51,8 @@ class LoggingMixin:
:param user: The user performing the action (optional)
"""
from pretix.api.models import OAuthAccessToken, OAuthApplication
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
from pretix.api.webhooks import notify_webhooks
from ..notifications import get_all_notification_types
from ..services.notifications import notify
from .devices import Device
from .event import Event
@@ -95,21 +94,11 @@ class LoggingMixin:
if save:
logentry.save()
no_types = get_all_notification_types()
wh_types = get_all_webhook_events()
no_type = None
wh_type = None
typepath = logentry.action_type
while (not no_type or not wh_types) and '.' in typepath:
wh_type = wh_type or wh_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
no_type = no_type or no_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
if no_type:
if logentry.notification_type:
notify.apply_async(args=(logentry.pk,))
if wh_type:
if logentry.webhook_type:
notify_webhooks.apply_async(args=(logentry.pk,))
return logentry

View File

@@ -222,3 +222,15 @@ class Device(LoggedModel):
return self.organizer.events.all()
else:
return self.limit_events.all()
def get_events_with_permission(self, permission, request=None):
"""
Returns a queryset of events the device has a specific permissions to.
:param request: Ignored, for compatibility with User model
:return: Iterable of Events
"""
if permission in self.permission_set():
return self.get_events_with_any_permission()
else:
return self.organizer.events.none()

View File

@@ -118,25 +118,49 @@ class EventMixin:
def timezone(self):
return pytz.timezone(self.settings.timezone)
@property
def effective_presale_end(self):
"""
Returns the effective presale end date, taking for subevents into consideration if the presale end
date might have been further limited by the event-level presale end date
"""
if isinstance(self, SubEvent):
presale_ends = [self.presale_end, self.event.presale_end]
return min(filter(lambda x: x is not None, presale_ends)) if any(presale_ends) else None
else:
return self.presale_end
@property
def presale_has_ended(self):
"""
Is true, when ``presale_end`` is set and in the past.
"""
if self.presale_end:
return now() > self.presale_end
if self.effective_presale_end:
return now() > self.effective_presale_end
elif self.date_to:
return now() > self.date_to
else:
return now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
@property
def effective_presale_start(self):
"""
Returns the effective presale start date, taking for subevents into consideration if the presale start
date might have been further limited by the event-level presale start date
"""
if isinstance(self, SubEvent):
presale_starts = [self.presale_start, self.event.presale_start]
return max(filter(lambda x: x is not None, presale_starts)) if any(presale_starts) else None
else:
return self.presale_start
@property
def presale_is_running(self):
"""
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
set or in the past.
"""
if self.presale_start and now() < self.presale_start:
if self.effective_presale_start and now() < self.effective_presale_start:
return False
return not self.presale_has_ended
@@ -244,6 +268,34 @@ class EventMixin:
return Quota.AVAILABILITY_RESERVED
return Quota.AVAILABILITY_GONE
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
qs_annotated = self._seats(ignore_voucher=ignore_voucher)
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
if self.settings.seating_minimal_distance > 0:
qs = qs.filter(has_closeby_taken=False)
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
qs = qs.filter(blocked=False)
return qs
def total_seats(self, ignore_voucher=None):
return self._seats(ignore_voucher=ignore_voucher)
def taken_seats(self, ignore_voucher=None):
return self._seats(ignore_voucher=ignore_voucher).filter(has_order=True)
def blocked_seats(self, ignore_voucher=None):
qs = self._seats(ignore_voucher=ignore_voucher)
q = (
Q(has_cart=True)
| Q(has_voucher=True)
| Q(blocked=True)
)
if self.settings.seating_minimal_distance > 0:
q |= Q(has_closeby_taken=True, has_order=False)
return qs.filter(q)
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
class Event(EventMixin, LoggedModel):
@@ -394,7 +446,7 @@ class Event(EventMixin, LoggedModel):
if img:
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
def _seats(self, ignore_voucher=None):
from .seating import Seat
qs_annotated = Seat.annotated(self.seats, self.pk, None,
@@ -402,13 +454,7 @@ class Event(EventMixin, LoggedModel):
minimal_distance=self.settings.seating_minimal_distance,
distance_only_within_row=self.settings.seating_distance_within_row)
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
if self.settings.seating_minimal_distance > 0:
qs = qs.filter(has_closeby_taken=False)
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
qs = qs.filter(blocked=False)
return qs
return qs_annotated
@property
def presale_has_ended(self):
@@ -507,11 +553,14 @@ class Event(EventMixin, LoggedModel):
def copy_data_from(self, other):
from ..signals import event_copy_data
from . import (
Item, ItemAddOn, ItemCategory, ItemMetaValue, Question, Quota,
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, Question,
Quota,
)
self.plugins = other.plugins
self.is_public = other.is_public
if other.date_admission:
self.date_admission = self.date_from + (other.date_admission - other.date_from)
self.testmode = other.testmode
self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
@@ -573,6 +622,14 @@ class Event(EventMixin, LoggedModel):
ia.addon_category = category_map[ia.addon_category.pk]
ia.save()
for ia in ItemBundle.objects.filter(base_item__event=other).prefetch_related('base_item', 'bundled_item', 'bundled_variation'):
ia.pk = None
ia.base_item = item_map[ia.base_item.pk]
ia.bundled_item = item_map[ia.bundled_item.pk]
if ia.bundled_variation:
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
ia.save()
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
items = list(q.items.all())
vars = list(q.variations.all())
@@ -1089,19 +1146,13 @@ class SubEvent(EventMixin, LoggedModel):
date_format(self.date_from.astimezone(self.timezone), "TIME_FORMAT") if self.settings.show_times else ""
).strip()
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
def _seats(self, ignore_voucher=None):
from .seating import Seat
qs_annotated = Seat.annotated(self.seats, self.event_id, self,
ignore_voucher_id=ignore_voucher.pk if ignore_voucher else None,
minimal_distance=self.settings.seating_minimal_distance,
distance_only_within_row=self.settings.seating_distance_within_row)
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
if self.settings.seating_minimal_distance > 0:
qs = qs.filter(has_closeby_taken=False)
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
qs = qs.filter(blocked=False)
return qs
return qs_annotated
@cached_property
def settings(self):

View File

@@ -314,7 +314,7 @@ class Item(LoggedModel):
)
allow_waitinglist = models.BooleanField(
verbose_name=_("Show a waiting list for this ticket"),
help_text=_("This will only work of waiting lists are enabled for this event."),
help_text=_("This will only work if waiting lists are enabled for this event."),
default=True
)
show_quota_left = models.NullBooleanField(

View File

@@ -63,14 +63,42 @@ class LogEntry(models.Model):
return response
return self.action_type
@property
def webhook_type(self):
from pretix.api.webhooks import get_all_webhook_events
wh_types = get_all_webhook_events()
wh_type = None
typepath = self.action_type
while not wh_type and '.' in typepath:
wh_type = wh_type or wh_types.get(typepath + ('.*' if typepath != self.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
return wh_type
@property
def notification_type(self):
from pretix.base.notifications import get_all_notification_types
no_type = None
no_types = get_all_notification_types()
typepath = self.action_type
while not no_type and '.' in typepath:
no_type = no_type or no_types.get(typepath + ('.*' if typepath != self.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
return no_type
@cached_property
def organizer(self):
from .organizer import Organizer
if self.event:
return self.event.organizer
elif hasattr(self.content_object, 'event'):
return self.content_object.event.organizer
elif hasattr(self.content_object, 'organizer'):
return self.content_object.organizer
elif isinstance(self.content_object, Organizer):
return self.content_object
return None
@cached_property
@@ -188,3 +216,16 @@ class LogEntry(models.Model):
def delete(self, using=None, keep_parents=False):
raise TypeError("Logs cannot be deleted.")
@classmethod
def bulk_postprocess(cls, objects):
from pretix.api.webhooks import notify_webhooks
from ..services.notifications import notify
to_notify = [o.id for o in objects if o.notification_type]
if to_notify:
notify.apply_async(args=(to_notify,))
to_wh = [o.id for o in objects if o.webhook_type]
if to_wh:
notify_webhooks.apply_async(args=(to_wh,))

View File

@@ -1600,6 +1600,10 @@ class OrderPayment(models.Model):
'local_id': r.local_id,
'provider': r.provider,
})
if self.order.pending_sum + r.amount == Decimal('0.00'):
self.refund.done()
return r

View File

@@ -357,3 +357,15 @@ class TeamAPIToken(models.Model):
return self.team.organizer.events.all()
else:
return self.team.limit_events.all()
def get_events_with_permission(self, permission, request=None):
"""
Returns a queryset of events the token has a specific permissions to.
:param request: Ignored, for compatibility with User model
:return: Iterable of Events
"""
if getattr(self.team, permission, False):
return self.get_events_with_any_permission()
else:
return self.team.organizer.events.none()

View File

@@ -130,7 +130,7 @@ class Seat(models.Model):
seat_number = models.CharField(max_length=190, blank=True, default="")
seat_label = models.CharField(max_length=190, null=True)
seat_guid = models.CharField(max_length=190, db_index=True)
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.SET_NULL)
blocked = models.BooleanField(default=False)
sorting_rank = models.BigIntegerField(default=0)
x = models.FloatField(null=True)

View File

@@ -4,6 +4,7 @@ from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.formats import localize
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _
from i18nfield.fields import I18nCharField
@@ -85,6 +86,14 @@ EU_CURRENCIES = {
}
def is_eu_country(cc):
cc = str(cc)
if cc == 'GB':
return now().astimezone(get_current_timezone()).year <= 2020
else:
return cc in EU_COUNTRIES
def cc_to_vat_prefix(country_code):
if country_code == 'GR':
return 'EL'
@@ -127,6 +136,9 @@ class TaxRule(LoggedModel):
class Meta:
ordering = ('event', 'rate', 'id')
class SaleNotAllowed(Exception):
pass
def allow_delete(self):
from pretix.base.models.orders import OrderFee, OrderPosition
@@ -169,6 +181,8 @@ class TaxRule(LoggedModel):
return Decimal('0.00')
if self.has_custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
return Decimal(rule.get('rate'))
return Decimal(self.rate)
@@ -241,7 +255,7 @@ class TaxRule(LoggedModel):
rules = self._custom_rules
if invoice_address:
for r in rules:
if r['country'] == 'EU' and str(invoice_address.country) not in EU_COUNTRIES:
if r['country'] == 'EU' and not is_eu_country(invoice_address.country):
continue
if r['country'] not in ('ZZ', 'EU') and r['country'] != str(invoice_address.country):
continue
@@ -265,7 +279,7 @@ class TaxRule(LoggedModel):
if not invoice_address or not invoice_address.country:
return False
if str(invoice_address.country) not in EU_COUNTRIES:
if not is_eu_country(invoice_address.country):
return False
if invoice_address.country == self.home_country:
@@ -279,6 +293,8 @@ class TaxRule(LoggedModel):
def _tax_applicable(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
return rule.get('action', 'vat') == 'vat'
if not self.eu_reverse_charge:
@@ -289,7 +305,7 @@ class TaxRule(LoggedModel):
# No country specified? Always apply VAT!
return True
if str(invoice_address.country) not in EU_COUNTRIES:
if not is_eu_country(invoice_address.country):
# Non-EU country? Never apply VAT!
return False

View File

@@ -513,7 +513,7 @@ class BasePaymentProvider:
return timing and pricing
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order=None) -> str:
"""
When the user selects this provider as their preferred payment method,
they will be shown the HTML you return from this method.
@@ -522,13 +522,15 @@ class BasePaymentProvider:
and render the returned form. If your payment method doesn't require
the user to fill out form fields, you should just return a paragraph
of explanatory text.
:param order: Only set when this is a change to a new payment method for an existing order.
"""
form = self.payment_form(request)
template = get_template('pretixpresale/event/checkout_payment_form_default.html')
ctx = {'request': request, 'form': form}
return template.render(ctx)
def checkout_confirm_render(self, request) -> str:
def checkout_confirm_render(self, request, order: Order=None) -> str:
"""
If the user has successfully filled in their payment data, they will be redirected
to a confirmation page which lists all details of their order for a final review.
@@ -537,6 +539,8 @@ class BasePaymentProvider:
In most cases, this should include a short summary of the user's input and
a short explanation on how the payment process will continue.
:param order: Only set when this is a change to a new payment method for an existing order.
"""
raise NotImplementedError() # NOQA

View File

@@ -1,4 +1,5 @@
import base64
import inspect
import struct
from cryptography.hazmat.backends.openssl.backend import Backend
@@ -52,10 +53,10 @@ class BaseTicketSecretGenerator:
return False
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
current_secret: str = None, force_invalidate=False) -> str:
attendee_name: str = None, current_secret: str = None, force_invalidate=False) -> str:
"""
Generate a new secret for a ticket with product ``item``, variation ``variation``, subevent ``subevent``,
and the current secret ``current_secret`` (if any).
attendee name ``attendee_name`` (can be ``None``) and the current secret ``current_secret`` (if any).
The result must be a string that should only contain the characters ``A-Za-z0-9+/=``.
@@ -70,6 +71,11 @@ class BaseTicketSecretGenerator:
If ``force_invalidate`` is set to ``False`` and ``item``, ``variation`` and ``subevent`` have a different value
as when ``current_secret`` was generated, then this method MAY OR MAY NOT return ``current_secret`` unchanged,
depending on the semantics of the method.
.. note:: While it is guaranteed that ``generate_secret`` and the revocation list process are called every
time the ``item``, ``variation``, or ``subevent`` parameters change, it is currently **NOT**
guaranteed that this process is triggered if the ``attendee_name`` parameter changes. You should
therefore not rely on this value for more than informational or debugging purposes.
"""
raise NotImplementedError()
@@ -80,7 +86,7 @@ class RandomTicketSecretGenerator(BaseTicketSecretGenerator):
use_revocation_list = False
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
current_secret: str = None, force_invalidate=False):
attendee_name: str = None, current_secret: str = None, force_invalidate=False):
if current_secret and not force_invalidate:
return current_secret
return get_random_string(
@@ -187,12 +193,17 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
gen = event.ticket_secret_generator
if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
force_invalidate = True
kwargs = {}
if 'attendee_name' in inspect.signature(gen.generate_secret).parameters:
kwargs['attendee_name'] = position.attendee_name
secret = gen.generate_secret(
item=position.item,
variation=position.variation,
subevent=position.subevent,
current_secret=position.secret,
force_invalidate=force_invalidate
force_invalidate=force_invalidate,
**kwargs
)
changed = position.secret != secret
if position.secret and changed and gen.use_revocation_list:

View File

@@ -65,7 +65,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
real_subject = str(subject).format_map(TolerantDict(email_context))
email_context = get_email_context(event_or_subevent=subevent or order.event,
email_context = get_email_context(event_or_subevent=p.subevent or order.event,
event=order.event,
refund_amount=refund_amount,
position_or_address=p,
@@ -82,11 +82,12 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
keep_fee_percentage: str, keep_fees: list=None, manual_refund: bool=False,
send: bool=False, send_subject: dict=None, send_message: dict=None,
def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
keep_fee_fixed: str, keep_fee_per_ticket: str, keep_fee_percentage: str, keep_fees: list=None,
manual_refund: bool=False, send: bool=False, send_subject: dict=None, send_message: dict=None,
send_waitinglist: bool=False, send_waitinglist_subject: dict={}, send_waitinglist_message: dict={},
user: int=None, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None):
user: int=None, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None,
subevents_from: str=None, subevents_to: str=None):
send_subject = LazyI18nString(send_subject)
send_message = LazyI18nString(send_message)
send_waitinglist_subject = LazyI18nString(send_waitinglist_subject)
@@ -102,14 +103,20 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
pcnt__gt=0
).all()
if subevent:
subevent = event.subevents.get(pk=subevent)
if subevent or subevents_from:
if subevent:
subevents = event.subevents.filter(pk=subevent)
subevent = subevents.first()
subevent_ids = {subevent.pk}
else:
subevents = event.subevents.filter(date_from__gte=subevents_from, date_from__lt=subevents_to)
subevent_ids = set(subevents.values_list('id', flat=True))
has_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).filter(
subevent=subevent
subevent__in=subevents
)
has_other_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).exclude(
subevent=subevent
subevent__in=subevents
)
orders_to_change = orders_to_cancel.annotate(
has_subevent=Exists(has_subevent),
@@ -124,15 +131,18 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
has_subevent=True, has_other_subevent=False
)
subevent.log_action(
'pretix.subevent.canceled', user=user,
)
subevent.active = False
subevent.save(update_fields=['active'])
subevent.log_action(
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
for se in subevents:
se.log_action(
'pretix.subevent.canceled', user=user,
)
se.active = False
se.save(update_fields=['active'])
se.log_action(
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
else:
subevents = None
subevent_ids = set()
orders_to_change = event.orders.none()
event.log_action(
'pretix.event.canceled', user=user,
@@ -146,7 +156,9 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
)
failed = 0
total = orders_to_cancel.count() + orders_to_change.count()
qs_wl = event.waitinglistentries.filter(subevent=subevent, voucher__isnull=True)
qs_wl = event.waitinglistentries.filter(voucher__isnull=True).select_related('subevent')
if subevents:
qs_wl = qs_wl.filter(subevent__in=subevents)
if send_waitinglist:
total += qs_wl.count()
counter = 0
@@ -170,6 +182,10 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * (o.total - fee_sum)
if keep_fee_fixed:
fee += Decimal(keep_fee_fixed)
if keep_fee_per_ticket:
for p in o.positions.all():
if p.addon_to_id is None:
fee += min(p.price, Decimal(keep_fee_per_ticket))
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
@@ -201,16 +217,20 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
with transaction.atomic():
o = event.orders.select_for_update().get(pk=o)
total = Decimal('0.00')
fee = Decimal('0.00')
positions = []
ocm = OrderChangeManager(o, user=user, notify=False)
for p in o.positions.all():
if p.subevent == subevent:
if p.subevent_id in subevent_ids:
total += p.price
ocm.cancel(p)
positions.append(p)
fee = Decimal('0.00')
if keep_fee_per_ticket:
if p.addon_to_id is None:
fee += min(p.price, Decimal(keep_fee_per_ticket))
if keep_fee_fixed:
fee += Decimal(keep_fee_fixed)
if keep_fee_percentage:
@@ -246,7 +266,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
if send_waitinglist:
for wle in qs_wl:
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, subevent)
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, wle.subevent)
counter += 1
if not self.request.called_directly and counter % max(10, total // 100) == 0:

View File

@@ -106,6 +106,7 @@ error_messages = {
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
'seat_multiple': _('You can not select the same seat multiple times.'),
'gift_card': _("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
'country_blocked': _('One of the selected products is not available in the selected country.'),
}
@@ -324,6 +325,8 @@ class CartManager:
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
invoice_address=self.invoice_address, force_custom_price=force_custom_price, bundled_sum=bundled_sum
)
except TaxRule.SaleNotAllowed:
raise CartError(error_messages['country_blocked'])
except ValueError as e:
if str(e) == 'price_too_high':
raise CartError(error_messages['price_too_high'])
@@ -1063,6 +1066,7 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
if pos.tax_rate != rate:
current_net = pos.price - pos.tax_value
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
totaldiff += new_gross - pos.price
pos.price = new_gross
pos.includes_tax = rate != Decimal('0.00')
pos.override_tax_rate = rate

View File

@@ -1,12 +1,13 @@
from typing import Any, Dict
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils.timezone import override
from django.utils.translation import gettext
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import (
CachedFile, Event, Organizer, User, cachedfile_name,
CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name,
)
from pretix.base.services.tasks import (
ProfiledEventTask, ProfiledOrganizerUserTask,
@@ -48,7 +49,13 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
@app.task(base=ProfiledOrganizerUserTask, throws=(ExportError,), bind=True)
def multiexport(self, organizer: Organizer, user: User, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
def multiexport(self, organizer: Organizer, user: User, device: int, token: int, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
if device:
device = Device.objects.get(pk=device)
if token:
device = TeamAPIToken.objects.get(pk=token)
allowed_events = (device or token or user).get_events_with_permission('can_view_orders')
def set_progress(val):
if not self.request.called_directly:
self.update_state(
@@ -57,10 +64,22 @@ def multiexport(self, organizer: Organizer, user: User, fileid: str, provider: s
)
file = CachedFile.objects.get(id=fileid)
with language(user.locale), override(user.timezone):
allowed_events = user.get_events_with_permission('can_view_orders')
events = allowed_events.filter(pk__in=form_data.get('events'))
if user:
locale = user.locale
timezone = user.timezone
else:
e = allowed_events.first()
if e:
locale = e.settings.locale
timezone = e.settings.timezone
else:
locale = settings.LANGUAGE_CODE
timezone = settings.TIME_ZONE
with language(locale), override(timezone):
if isinstance(form_data['events'][0], str):
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
else:
events = allowed_events.filter(pk__in=form_data.get('events'))
responses = register_multievent_data_exporters.send(organizer)
for receiver, response in responses:

View File

@@ -24,7 +24,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
)
from pretix.base.models.tax import EU_COUNTRIES, EU_CURRENCIES
from pretix.base.models.tax import EU_CURRENCIES, is_eu_country
from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import invoice_line_text, periodic_task
@@ -181,7 +181,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
if reverse_charge:
if invoice.additional_text:
invoice.additional_text += "<br /><br />"
if str(invoice.invoice_to_country) in EU_COUNTRIES:
if is_eu_country(invoice.invoice_to_country):
invoice.additional_text += pgettext(
"invoice",
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "

View File

@@ -372,7 +372,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
backend.send_messages([email])
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))
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
logger.exception('Error sending email')
if order:
@@ -389,7 +389,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
if isinstance(e, (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, ssl.SSLError, OSError)):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
if order:
order.log_action(
'pretix.event.order.email.error',

View File

@@ -15,55 +15,59 @@ from pretix.helpers.urls import build_absolute_uri
@app.task(base=TransactionAwareTask, acks_late=True)
@scopes_disabled()
def notify(logentry_id: int):
logentry = LogEntry.all.select_related('event', 'event__organizer').get(id=logentry_id)
if not logentry.event:
return # Ignore, we only have event-related notifications right now
types = get_all_notification_types(logentry.event)
def notify(logentry_ids: list):
if not isinstance(logentry_ids, list):
logentry_ids = [logentry_ids]
notification_type = None
typepath = logentry.action_type
while not notification_type and '.' in typepath:
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
typepath = typepath.rsplit('.', 1)[0]
qs = LogEntry.all.select_related('event', 'event__organizer').filter(id__in=logentry_ids)
if not notification_type:
return # No suitable plugin
_event, _at, notify_specific, notify_global = None, None, None, None
for logentry in qs:
if not logentry.event:
break # Ignore, we only have event-related notifications right now
# All users that have the permission to get the notification
users = logentry.event.get_users_with_permission(
notification_type.required_permission
).filter(notifications_send=True, is_active=True)
if logentry.user:
users = users.exclude(pk=logentry.user.pk)
notification_type = logentry.notification_type
# Get all notification settings, both specific to this event as well as global
notify_specific = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
notify_global = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event__isnull=True,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
if not notification_type:
break # No suitable plugin
for um, enabled in notify_specific.items():
user, method = um
if enabled:
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
if _event != logentry.event or _at != logentry.action_type or notify_global is None:
_event = logentry.event
_at = logentry.action_type
# All users that have the permission to get the notification
users = logentry.event.get_users_with_permission(
notification_type.required_permission
).filter(notifications_send=True, is_active=True)
if logentry.user:
users = users.exclude(pk=logentry.user.pk)
for um, enabled in notify_global.items():
user, method = um
if enabled and um not in notify_specific:
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
# Get all notification settings, both specific to this event as well as global
notify_specific = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
notify_global = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event__isnull=True,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
for um, enabled in notify_specific.items():
user, method = um
if enabled:
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
for um, enabled in notify_global.items():
user, method = um
if enabled and um not in notify_specific:
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
@app.task(base=ProfiledTask, acks_late=True)

View File

@@ -89,6 +89,7 @@ error_messages = {
'positions have been removed from your cart.'),
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
'country_blocked': _('One of the selected products is not available in the selected country.'),
}
logger = logging.getLogger(__name__)
@@ -615,34 +616,39 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
current_discount = cp.price_before_voucher - cp.price
max_discount = max(v_budget[cp.voucher] + current_discount, 0)
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
bprice = bundle.designated_price or 0
except ItemBundle.DoesNotExist:
bprice = cp.price
except ItemBundle.MultipleObjectsReturned:
raise OrderError("Invalid product configuration (duplicate bundle)")
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
bundled_sum = 0
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
try:
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
bprice = bundle.designated_price or 0
except ItemBundle.DoesNotExist:
bprice = cp.price
except ItemBundle.MultipleObjectsReturned:
raise OrderError("Invalid product configuration (duplicate bundle)")
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
bundled_sum = 0
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
except TaxRule.SaleNotAllowed:
err = err or error_messages['country_blocked']
cp.delete()
continue
if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
@@ -1174,6 +1180,7 @@ class OrderChangeManager:
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
'seat_required': _('The selected product requires you to select a seat.'),
'seat_forbidden': _('The selected product does not allow to select a seat.'),
'tax_rule_country_blocked': _('The selected country is blocked by your tax rule.'),
'gift_card_change': _('You cannot change the price of a position that has been used to issue a gift card.'),
}
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
@@ -1241,8 +1248,11 @@ class OrderChangeManager:
self._operations.append(self.SeatOperation(position, seat))
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address)
try:
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address)
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
if price is None: # NOQA
raise OrderError(self.error_messages['product_invalid'])
@@ -1262,8 +1272,11 @@ class OrderChangeManager:
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation'])
price = get_price(item, variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address)
try:
price = get_price(item, variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address)
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
if price is None: # NOQA
raise OrderError(self.error_messages['product_invalid'])
@@ -1321,7 +1334,10 @@ class OrderChangeManager:
if not pos.price:
continue
new_rate = tax_rule.tax_rate_for(ia)
try:
new_rate = tax_rule.tax_rate_for(ia)
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['tax_rule_country_blocked'])
# We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself
if new_rate != pos.tax_rate:
if keep == 'net':
@@ -1374,10 +1390,13 @@ class OrderChangeManager:
except Seat.DoesNotExist:
raise OrderError(error_messages['seat_invalid'])
if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address)
try:
if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address)
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
if price is None:
raise OrderError(self.error_messages['product_invalid'])
@@ -1952,7 +1971,10 @@ class OrderChangeManager:
self._check_quotas()
self._check_seats()
self._check_complete_cancel()
self._perform_operations()
try:
self._perform_operations()
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
self._recalculate_total_and_payment_fee()
self._reissue_invoice()
self._clear_tickets_cache()

View File

@@ -127,6 +127,7 @@ def order_overview(
order__event=event
).annotate(
status=Case(
When(order__status='n', order__require_approval=True, then=Value('unapproved')),
When(canceled=True, then=Value('c')),
default=F('order__status')
)
@@ -135,6 +136,7 @@ def order_overview(
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
states = {
'unapproved': 'unapproved',
'canceled': Order.STATUS_CANCELED,
'paid': Order.STATUS_PAID,
'pending': Order.STATUS_PENDING,
@@ -198,6 +200,7 @@ def order_overview(
order__event=event
).annotate(
status=Case(
When(order__status='n', order__require_approval=True, then=Value('unapproved')),
When(canceled=True, then=Value('c')),
default=F('order__status')
)

View File

@@ -96,8 +96,9 @@ class OrganizerUserTask(app.Task):
kwargs['organizer'] = organizer
user_id = kwargs['user']
user = User.objects.get(pk=user_id)
kwargs['user'] = user
if user_id is not None:
user = User.objects.get(pk=user_id)
kwargs['user'] = user
with scope(organizer=organizer):
ret = super().__call__(*args, **kwargs)

View File

@@ -2049,7 +2049,7 @@ PERSON_NAME_SCHEMES = OrderedDict([
'title': pgettext_lazy('person_name_sample', 'Dr'),
'given_name': pgettext_lazy('person_name_sample', 'John'),
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'title_salutation_given_family',
'_scheme': 'salutation_title_given_family',
},
}),
])

View File

@@ -4,3 +4,4 @@
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}{% if widget.value.is_img %}<br><a href="{{ widget.value.url }}" data-lightbox="{{ widget.value.name }}"><img src="{{ widget.value|thumb:"200x100" }}" /></a>{% endif %}<br>
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% if widget.cachedfile %}<input type="hidden" name="{{ widget.hidden_name }}" value="{{ widget.cachedfile.id }}">{% endif %}

View File

@@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError
from django.core.files import File
from django.core.files.uploadedfile import UploadedFile
from django.forms.utils import from_current_timezone
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from ...base.forms import I18nModelForm
@@ -77,6 +78,8 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
@property
def name(self):
if hasattr(self.file, 'display_name'):
return self.file.display_name
return self.file.name
@property
@@ -84,6 +87,8 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
return any(self.file.name.lower().endswith(e) for e in ('.jpg', '.jpeg', '.png', '.gif'))
def __str__(self):
if hasattr(self.file, 'display_name'):
return self.file.display_name
return os.path.basename(self.file.name).split('.', 1)[-1]
@property
@@ -93,6 +98,48 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
def get_context(self, name, value, attrs):
ctx = super().get_context(name, value, attrs)
ctx['widget']['value'] = self.FakeFile(value)
ctx['widget']['cachedfile'] = None
return ctx
class CachedFileInput(forms.ClearableFileInput):
template_name = 'pretixbase/forms/widgets/thumbnailed_file_input.html'
class FakeFile(File):
def __init__(self, file):
self.file = file
@property
def name(self):
return self.file.filename
@property
def is_img(self):
return any(self.file.filename.lower().endswith(e) for e in ('.jpg', '.jpeg', '.png', '.gif'))
def __str__(self):
return self.file.filename
@property
def url(self):
return self.file.file.url
def value_from_datadict(self, data, files, name):
from ...base.models import CachedFile
v = super().value_from_datadict(data, files, name)
if v is None and data.get(name + '-cachedfile'): # An explicit "[x] clear" would be False, not None
return CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
return v
def get_context(self, name, value, attrs):
from ...base.models import CachedFile
if isinstance(value, CachedFile):
value = self.FakeFile(value)
ctx = super().get_context(name, value, attrs)
ctx['widget']['value'] = value
ctx['widget']['cachedfile'] = value.file if isinstance(value, self.FakeFile) else None
ctx['widget']['hidden_name'] = name + '-cachedfile'
return ctx
@@ -129,7 +176,7 @@ class ExtFileField(SizeFileField):
def clean(self, *args, **kwargs):
data = super().clean(*args, **kwargs)
if data:
if isinstance(data, File):
filename = data.name
ext = os.path.splitext(filename)[1]
ext = ext.lower()
@@ -138,6 +185,51 @@ class ExtFileField(SizeFileField):
return data
class CachedFileField(ExtFileField):
widget = CachedFileInput
def to_python(self, data):
from ...base.models import CachedFile
if isinstance(data, CachedFile):
return data
return super().to_python(data)
def bound_data(self, data, initial):
from ...base.models import CachedFile
if isinstance(data, File):
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
date=now(),
web_download=True,
filename=data.name,
type=data.content_type,
)
cf.file.save(data.name, data.file)
cf.save()
return cf
return super().bound_data(data, initial)
def clean(self, *args, **kwargs):
from ...base.models import CachedFile
data = super().clean(*args, **kwargs)
if isinstance(data, File):
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
web_download=True,
date=now(),
filename=data.name,
type=data.content_type,
)
cf.file.save(data.name, data.file)
cf.save()
return cf
return data
class SlugWidget(forms.TextInput):
template_name = 'pretixcontrol/slug_widget.html'
prefix = ''

View File

@@ -657,6 +657,8 @@ class ProviderForm(SettingsForm):
enabled = cleaned_data.get(self.settingspref + '_enabled')
if not enabled:
return
if cleaned_data.get(self.settingspref + '_hidden_url', None):
cleaned_data[self.settingspref + '_hidden_url'] = None
for k, v in self.fields.items():
val = cleaned_data.get(k)
if v._required and not val:
@@ -1146,6 +1148,7 @@ class TaxRuleLineForm(forms.Form):
('vat', _('Charge VAT')),
('reverse', _('Reverse charge')),
('no', _('No VAT')),
('block', _('Sale not allowed')),
],
)
rate = forms.DecimalField(

View File

@@ -1,16 +1,22 @@
from datetime import datetime, time
from decimal import Decimal
from urllib.parse import urlencode
from django import forms
from django.apps import apps
from django.db.models import Exists, F, OuterRef, Q
from django.conf import settings
from django.db.models import Exists, F, Model, OuterRef, Q, QuerySet
from django.db.models.functions import Coalesce, ExtractWeekDay
from django.urls import reverse, reverse_lazy
from django.utils.formats import date_format, localize
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.widgets import (
DatePickerWidget, SplitDateTimePickerWidget,
)
from pretix.base.models import (
Checkin, Event, EventMetaProperty, EventMetaValue, Invoice, InvoiceAddress,
Item, Order, OrderPayment, OrderPosition, OrderRefund, Organizer, Question,
@@ -19,7 +25,9 @@ from pretix.base.models import (
from pretix.base.signals import register_payment_providers
from pretix.control.forms.widgets import Select2
from pretix.control.signals import order_search_filter_q
from pretix.helpers.countries import CachedCountries
from pretix.helpers.database import FixedOrderBy, rolledback_transaction
from pretix.helpers.dicts import move_to_end
from pretix.helpers.i18n import i18ncomp
PAYMENT_PROVIDERS = []
@@ -83,6 +91,38 @@ class FilterForm(forms.Form):
else:
return self.orders[o]
def filter_to_strings(self):
string = []
for k, f in self.fields.items():
v = self.cleaned_data.get(k)
if v is None or (isinstance(v, (list, str, QuerySet)) and len(v) == 0):
continue
if k == "saveas":
continue
if isinstance(v, bool):
val = _('Yes') if v else _('No')
elif isinstance(v, QuerySet):
q = ['"' + str(m) + '"' for m in v]
if not q:
continue
val = ' or '.join(q)
elif isinstance(v, Model):
val = '"' + str(v) + '"'
elif isinstance(f, forms.MultipleChoiceField):
valdict = dict(f.choices)
val = ' or '.join([str(valdict.get(m)) for m in v])
elif isinstance(f, forms.ChoiceField):
val = str(dict(f.choices).get(v))
elif isinstance(v, datetime):
val = date_format(v, 'SHORT_DATETIME_FORMAT')
elif isinstance(v, Decimal):
val = localize(v)
else:
val = v
string.append('{}: {}'.format(f.label, val))
return string
class OrderFilterForm(FilterForm):
query = forms.CharField(
@@ -104,20 +144,29 @@ class OrderFilterForm(FilterForm):
label=_('Order status'),
choices=(
('', _('All orders')),
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
(Order.STATUS_PENDING, _('Pending')),
('o', _('Pending (overdue)')),
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
(Order.STATUS_EXPIRED, _('Expired')),
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
(Order.STATUS_CANCELED, _('Canceled')),
('cp', _('Canceled (or with paid fee)')),
('pa', _('Approval pending')),
('overpaid', _('Overpaid')),
('underpaid', _('Underpaid')),
('pendingpaid', _('Pending (but fully paid)')),
(_('Valid orders'), (
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
(Order.STATUS_PENDING, _('Pending')),
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
)),
(_('Cancellations'), (
(Order.STATUS_CANCELED, _('Canceled')),
('cp', _('Canceled (or with paid fee)')),
('rc', _('Cancellation requested')),
)),
(_('Payment process'), (
(Order.STATUS_EXPIRED, _('Expired')),
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
('o', _('Pending (overdue)')),
('overpaid', _('Overpaid')),
('underpaid', _('Underpaid')),
('pendingpaid', _('Pending (but fully paid)')),
)),
(_('Approval process'), (
('na', _('Approved, payment pending')),
('pa', _('Approval pending')),
)),
('testmode', _('Test mode')),
('rc', _('Cancellation requested')),
),
required=False,
)
@@ -207,6 +256,11 @@ class OrderFilterForm(FilterForm):
status=Order.STATUS_PENDING,
require_approval=True
)
elif s == 'na':
qs = qs.filter(
status=Order.STATUS_PENDING,
require_approval=False
)
elif s == 'testmode':
qs = qs.filter(
testmode=True
@@ -337,6 +391,238 @@ class EventOrderFilterForm(OrderFilterForm):
return qs
class FilterNullBooleanSelect(forms.NullBooleanSelect):
def __init__(self, attrs=None):
choices = (
('unknown', _('All')),
('true', _('Yes')),
('false', _('No')),
)
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
class EventOrderExpertFilterForm(EventOrderFilterForm):
subevents_from = forms.SplitDateTimeField(
widget=SplitDateTimePickerWidget(attrs={
}),
label=pgettext_lazy('subevent', 'All dates starting at or after'),
required=False,
)
subevents_to = forms.SplitDateTimeField(
widget=SplitDateTimePickerWidget(attrs={
}),
label=pgettext_lazy('subevent', 'All dates starting before'),
required=False,
)
created_from = forms.SplitDateTimeField(
widget=SplitDateTimePickerWidget(attrs={
}),
label=_('Order placed at or after'),
required=False,
)
created_to = forms.SplitDateTimeField(
widget=SplitDateTimePickerWidget(attrs={
}),
label=_('Order placed before'),
required=False,
)
email = forms.CharField(
required=False,
label=_('E-mail address')
)
comment = forms.CharField(
required=False,
label=_('Comment')
)
locale = forms.ChoiceField(
required=False,
label=_('Locale'),
choices=settings.LANGUAGES
)
email_known_to_work = forms.NullBooleanField(
required=False,
widget=FilterNullBooleanSelect,
label=_('E-mail address verified'),
)
total = forms.DecimalField(
localize=True,
required=False,
label=_('Total amount'),
)
sales_channel = forms.ChoiceField(
label=_('Sales channel'),
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
del self.fields['query']
del self.fields['question']
del self.fields['answer']
del self.fields['ordering']
if not self.event.has_subevents:
del self.fields['subevents_from']
del self.fields['subevents_to']
self.fields['sales_channel'].choices = [('', '')] + [
(k, v.verbose_name) for k, v in get_all_sales_channels().items()
]
locale_names = dict(settings.LANGUAGES)
self.fields['locale'].choices = [('', '')] + [(a, locale_names[a]) for a in self.event.settings.locales]
move_to_end(self.fields, 'item')
move_to_end(self.fields, 'provider')
self.fields['invoice_address_company'] = forms.CharField(
required=False,
label=gettext('Invoice address') + ': ' + gettext('Company')
)
self.fields['invoice_address_name'] = forms.CharField(
required=False,
label=gettext('Invoice address') + ': ' + gettext('Name')
)
self.fields['invoice_address_street'] = forms.CharField(
required=False,
label=gettext('Invoice address') + ': ' + gettext('Address')
)
self.fields['invoice_address_zipcode'] = forms.CharField(
required=False,
label=gettext('Invoice address') + ': ' + gettext('ZIP code'),
help_text=_('Exact matches only')
)
self.fields['invoice_address_city'] = forms.CharField(
required=False,
label=gettext('Invoice address') + ': ' + gettext('City'),
help_text=_('Exact matches only')
)
self.fields['invoice_address_country'] = forms.ChoiceField(
required=False,
label=gettext('Invoice address') + ': ' + gettext('Country'),
choices=[('', '')] + list(CachedCountries())
)
self.fields['attendee_name'] = forms.CharField(
required=False,
label=_('Attendee name')
)
self.fields['attendee_email'] = forms.CharField(
required=False,
label=_('Attendee e-mail address')
)
self.fields['attendee_address_company'] = forms.CharField(
required=False,
label=gettext('Attendee address') + ': ' + gettext('Company')
)
self.fields['attendee_address_street'] = forms.CharField(
required=False,
label=gettext('Attendee address') + ': ' + gettext('Address')
)
self.fields['attendee_address_zipcode'] = forms.CharField(
required=False,
label=gettext('Attendee address') + ': ' + gettext('ZIP code'),
help_text=_('Exact matches only')
)
self.fields['attendee_address_city'] = forms.CharField(
required=False,
label=gettext('Attendee address') + ': ' + gettext('City'),
help_text=_('Exact matches only')
)
self.fields['attendee_address_country'] = forms.ChoiceField(
required=False,
label=gettext('Attendee address') + ': ' + gettext('Country'),
choices=[('', '')] + list(CachedCountries())
)
self.fields['ticket_secret'] = forms.CharField(
label=_('Ticket secret'),
required=False
)
for q in self.event.questions.all():
self.fields['question_{}'.format(q.pk)] = forms.CharField(
label=q.question,
required=False,
help_text=_('Exact matches only')
)
def filter_qs(self, qs):
fdata = self.cleaned_data
qs = super().filter_qs(qs)
if fdata.get('subevents_from'):
qs = qs.filter(
all_positions__subevent__date_from__gte=fdata.get('subevents_from'),
all_positions__canceled=False
).distinct()
if fdata.get('subevents_to'):
qs = qs.filter(
all_positions__subevent__date_from__lt=fdata.get('subevents_to'),
all_positions__canceled=False
).distinct()
if fdata.get('email'):
qs = qs.filter(
email__icontains=fdata.get('email')
)
if fdata.get('created_from'):
qs = qs.filter(datetime__gte=fdata.get('created_from'))
if fdata.get('created_to'):
qs = qs.filter(datetime__gte=fdata.get('created_to'))
if fdata.get('comment'):
qs = qs.filter(comment__icontains=fdata.get('comment'))
if fdata.get('sales_channel'):
qs = qs.filter(sales_channel=fdata.get('sales_channel'))
if fdata.get('total'):
qs = qs.filter(total=fdata.get('total'))
if fdata.get('email_known_to_work') is not None:
qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work'))
if fdata.get('locale'):
qs = qs.filter(locale=fdata.get('locale'))
if fdata.get('invoice_address_company'):
qs = qs.filter(invoice_address__company__icontains=fdata.get('invoice_address_company'))
if fdata.get('invoice_address_name'):
qs = qs.filter(invoice_address__name_cached__icontains=fdata.get('invoice_address_name'))
if fdata.get('invoice_address_street'):
qs = qs.filter(invoice_address__street__icontains=fdata.get('invoice_address_street'))
if fdata.get('invoice_address_zipcode'):
qs = qs.filter(invoice_address__zipcode__iexact=fdata.get('invoice_address_zipcode'))
if fdata.get('invoice_address_city'):
qs = qs.filter(invoice_address__city__iexact=fdata.get('invoice_address_city'))
if fdata.get('invoice_address_country'):
qs = qs.filter(invoice_address__country=fdata.get('invoice_address_country'))
if fdata.get('attendee_name'):
qs = qs.filter(
all_positions__attendee_name_cached__icontains=fdata.get('attendee_name')
)
if fdata.get('attendee_address_company'):
qs = qs.filter(
all_positions__company__icontains=fdata.get('attendee_address_company')
).distinct()
if fdata.get('attendee_address_street'):
qs = qs.filter(
all_positions__street__icontains=fdata.get('attendee_address_street')
).distinct()
if fdata.get('attendee_address_city'):
qs = qs.filter(
all_positions__city__iexact=fdata.get('attendee_address_city')
).distinct()
if fdata.get('attendee_address_country'):
qs = qs.filter(
all_positions__country=fdata.get('attendee_address_country')
).distinct()
if fdata.get('ticket_secret'):
qs = qs.filter(
all_positions__secret__icontains=fdata.get('ticket_secret')
).distinct()
for q in self.event.questions.all():
if fdata.get(f'question_{q.pk}'):
answers = QuestionAnswer.objects.filter(
question_id=q.pk,
orderposition__order_id=OuterRef('pk'),
answer__iexact=fdata.get(f'question_{q.pk}')
)
qs = qs.annotate(**{f'q_{q.pk}': Exists(answers)}).filter(**{f'q_{q.pk}': True})
return qs
class OrderSearchFilterForm(OrderFilterForm):
orders = {'code': 'code', 'email': 'email', 'total': 'total',
'datetime': 'datetime', 'status': 'status',

View File

@@ -226,6 +226,8 @@ class ItemCreateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
self.user = kwargs.pop('user')
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('admission', True)
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()

View File

@@ -400,7 +400,6 @@ class OrderPositionChangeForm(forms.Form):
self.fields['tax_rule'].label_from_instance = self.taxrule_label_from_instance
if not instance.seat and not (
not instance.event.settings.seating_choice and
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
):
del self.fields['seat']
@@ -517,6 +516,20 @@ class OrderMailForm(forms.Form):
self._set_field_placeholders('message', ['event', 'order'])
class OrderPositionMailForm(OrderMailForm):
def __init__(self, *args, **kwargs):
position = self.position = kwargs.pop('position')
super().__init__(*args, **kwargs)
self.fields['sendto'].initial = position.attendee_email
self.fields['message'] = forms.CharField(
label=_("Message"),
required=True,
widget=forms.Textarea,
initial=self.order.event.settings.mail_text_order_custom_mail.localize(self.order.locale),
)
self._set_field_placeholders('message', ['event', 'order', 'position'])
class OrderRefundForm(forms.Form):
action = forms.ChoiceField(
required=False,
@@ -572,7 +585,21 @@ class EventCancelForm(forms.Form):
all_subevents = forms.BooleanField(
label=_('Cancel all dates'),
initial=False,
required=False
required=False,
)
subevents_from = forms.SplitDateTimeField(
widget=SplitDateTimePickerWidget(attrs={
'data-inverse-dependency': '#id_all_subevents',
}),
label=pgettext_lazy('subevent', 'All dates starting at or after'),
required=False,
)
subevents_to = forms.SplitDateTimeField(
widget=SplitDateTimePickerWidget(attrs={
'data-inverse-dependency': '#id_all_subevents',
}),
label=pgettext_lazy('subevent', 'All dates starting before'),
required=False,
)
auto_refund = forms.BooleanField(
label=_('Automatically refund money if possible'),
@@ -613,6 +640,12 @@ class EventCancelForm(forms.Form):
max_digits=10, decimal_places=2,
required=False
)
keep_fee_per_ticket = forms.DecimalField(
label=_("Keep a fixed cancellation fee per ticket"),
help_text=_("Free tickets and add-on products are not counted"),
max_digits=10, decimal_places=2,
required=False
)
keep_fee_percentage = forms.DecimalField(
label=_("Keep a percentual cancellation fee"),
max_digits=10, decimal_places=2,
@@ -717,6 +750,7 @@ class EventCancelForm(forms.Form):
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-inverse-dependency': '#id_all_subevents',
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
@@ -733,6 +767,12 @@ class EventCancelForm(forms.Form):
def clean(self):
d = super().clean()
if self.event.has_subevents and not d['subevent'] and not d['all_subevents']:
if d.get('subevent') and d.get('subevents_from'):
raise ValidationError(pgettext_lazy('subevent', 'Please either select a specific date or a date range, not both.'))
if d.get('all_subevents') and d.get('subevent_from'):
raise ValidationError(pgettext_lazy('subevent', 'Please either select all dates or a date range, not both.'))
if bool(d.get('subevents_from')) != bool(d.get('subevents_to')):
raise ValidationError(pgettext_lazy('subevent', 'If you set a date range, please set both a start and an end.'))
if self.event.has_subevents and not d['subevent'] and not d['all_subevents'] and not d.get('subevents_from'):
raise ValidationError(_('Please confirm that you want to cancel ALL dates in this event series.'))
return d

View File

@@ -397,7 +397,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.testmode.activated': _('The shop has been taken into test mode.'),
'pretix.event.testmode.deactivated': _('The test mode has been disabled.'),
'pretix.event.added': _('The event has been created.'),
'pretix.event.changed': _('The event settings have been changed.'),
'pretix.event.changed': _('The event details have been changed.'),
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
'pretix.event.question.option.changed': _('An answer option has been changed.'),

View File

@@ -176,7 +176,7 @@ def get_event_navigation(request: HttpRequest):
'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,
'active': url.url_name in ('event.orders', 'event.order', 'event.orders.search') or "event.order." in url.url_name,
},
{
'label': _('Overview'),

View File

@@ -323,3 +323,19 @@ this is not an Event signal and will be called even if your plugin is not active
event if the search is performed within an event, and ``None`` otherwise. The search query will be passed as
``query``.
"""
order_search_forms = EventPluginSignal(
providing_args=['request']
)
"""
This signal allows you to return additional forms that should be rendered in the advanced order search.
You are passed ``request`` argument and are expected to return an instance of a form class that you bind
yourself when appropriate. Your form will be executed as part of the standard validation and rendering
cycle and rendered using default bootstrap styles.
You are required to set ``prefix`` on your form instance. You are required to implement a ``filter_qs(queryset)``
method on your form that returns a new, filtered query set. You are required to implement a ``filter_to_strings()``
method on your form that returns a list of strings describing the currently active filters.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""

View File

@@ -4,11 +4,11 @@
{% block form %}
{% bootstrap_field form.organizer layout="horizontal" %}
<div class="form-group">
<label class="col-md-3 control-label">Event type</label>
<label class="col-md-3 control-label">{% trans "Event type" %}</label>
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.has_subevents.html_name }}">
<input type="radio" value="" name="{{ form.has_subevents.html_name }}" {% if not form.has_subevents.value %}checked{% endif %}>
<span class="fa fa-calendar-o"></span>
<strong>{% trans "Singular event or non-event shop" %}</strong><br>
<div class="help-block">
@@ -27,7 +27,7 @@
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.has_subevents.html_name }}">
<input type="radio" value="on" name="{{ form.has_subevents.html_name }}" {% if not form.has_subevents.value %}checked{% endif %}>
<span class="fa fa-calendar"></span>
<strong>{% trans "Event series or time slot booking" %}</strong>
<div class="help-block">

View File

@@ -14,9 +14,84 @@
{% bootstrap_field form.internal_name layout="control" %}
</div>
{% bootstrap_field form.copy_from layout="control" %}
{% bootstrap_field form.has_variations layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Product type" %}</label>
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
<span class="fa fa-user"></span>
<strong>{% trans "Admission product" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
Every purchase of this product represents one person who is allowed to enter your event.
By default, pretix will only ask for attendee information and offer ticket downloads for these products.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
This option should be set for most things that you would call a "ticket". For product add-ons or bundles, this should
be set on the main ticket, except if the add-on products or bundled products represent additional people (e.g. group bundles).
{% endblocktrans %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.admission.html_name }}" {% if not form.admission.value %}checked{% endif %}>
<span class="fa fa-cube"></span>
<strong>{% trans "Non-admission product" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
A product that does not represent a person. By default, pretix will not ask for attendee information or offer
ticket downloads.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
Examples: Merchandise, donations, gift cards, add-ons to a main ticket.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.category layout="control" %}
{% bootstrap_field form.admission layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Product variations" %}</label>
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.has_variations.html_name }}" {% if not form.has_variations.value %}checked{% endif %}>
<span class="fa fa-fw fa-square"></span>
<strong>{% trans "Product without variations" %}</strong><br>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.has_variations.html_name }}" {% if form.has_variations.value %}checked{% endif %}>
<span class="fa fa-fw fa-th-large"></span>
<strong>{% trans "Product with multiple variations" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
This product exists in multiple variations which are different in either their name, price, quota, or description.
All other settings need to be the same.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
Examples: Ticket category with variations for "full price" and "reduced", merchandise with variations for different sizes,
workshop add-on with variations for simultaneous workshops.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
</fieldset>
{% if form.quota_option %}
<fieldset>

View File

@@ -10,13 +10,56 @@
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.active layout="control" %}
{% bootstrap_field form.name layout="control" %}
<div class="internal-name-wrapper">
{% bootstrap_field form.internal_name layout="control" %}
</div>
{% bootstrap_field form.category layout="control" %}
{% bootstrap_field form.active layout="control" %}
{% bootstrap_field form.admission layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Product type" %}</label>
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
<span class="fa fa-fw fa-user"></span>
<strong>{% trans "Admission product" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
Every purchase of this product represents one person who is allowed to enter your event.
By default, pretix will only ask for attendee information and offer ticket downloads for these products.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
This option should be set for most things that you would call a "ticket". For product add-ons or bundles, this should
be set on the main ticket, except if the add-on products or bundled products represent additional people (e.g. group bundles).
{% endblocktrans %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.admission.html_name }}" {% if not form.admission.value %}checked{% endif %}>
<span class="fa fa-fw fa-cube"></span>
<strong>{% trans "Non-admission product" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
A product that does not represent a person. By default, pretix will not ask for attendee information or offer
ticket downloads.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
Examples: Merchandise, donations, gift cards, add-ons to a main ticket.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.description layout="control" %}
{% bootstrap_field form.picture layout="control" %}
{% bootstrap_field form.require_approval layout="control" %}

View File

@@ -73,7 +73,7 @@
</td>
<td>
{% if i.var_count %}
<span class="fa fa-list-ul fa-fw text-muted" data-toggle="tooltip" title="{% trans "Product with variations" %}"></span>
<span class="fa fa-th-large fa-fw text-muted" data-toggle="tooltip" title="{% trans "Product with variations" %}"></span>
{% endif %}
</td>
<td>

View File

@@ -182,7 +182,7 @@
{{ order.email|default_if_none:"" }}
{% if order.email and order.email_known_to_work %}
<span class="fa fa-check-circle text-success" data-toggle="tooltip" title="{% trans "We know that this email address works because the user clicked a link we sent them." %}"></span>
{% endif %}&nbsp;&nbsp;
{% endif %}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
@@ -268,12 +268,12 @@
<div class="panel panel-default items">
<div class="panel-heading">
<div class="pull-right flip">
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Change answers" %}
</a>
{% if order.changable and 'can_change_orders' in request.eventpermset %}
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Change answers" %}
</a> &middot;
<a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
&middot; <a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Change products" %}
</a>
@@ -388,8 +388,12 @@
{{ line.attendee_email }}
{% if not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>

View File

@@ -25,22 +25,30 @@
<strong>{{ pending }}</strong>. The order total is <strong>{{ total }}</strong>.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed with amount=refund.amount|money:request.event.currency method=refund.payment_provider.verbose_name %}
What should happen to the ticket order?
{% endblocktrans %}
</p>
<div class="form-inline">
<label class="radio">
<input type="radio" name="action" value="n" {% if not propose_cancel %}checked{% endif %}>
{% trans "Mark the order as unpaid and allow the customer to pay again with another payment method." %}
</label>
<br>
<label class="radio">
<input type="radio" name="action" value="r" {% if propose_cancel %}checked{% endif %}>
{% trans "Cancel the order irrevocably." %}
</label>
</div>
{% if order.status == "c" or order.positions.count == 0 %}
<p>
{% blocktrans trimmed %}
Since the order is already canceled, this will not affect its state.
{% endblocktrans %}
</p>
{% else %}
<p>
{% blocktrans trimmed with amount=refund.amount|money:request.event.currency method=refund.payment_provider.verbose_name %}
What should happen to the ticket order?
{% endblocktrans %}
</p>
<div class="form-inline">
<label class="radio">
<input type="radio" name="action" value="n" {% if not propose_cancel %}checked{% endif %}>
{% trans "Mark the order as unpaid and allow the customer to pay again with another payment method." %}
</label>
<br>
<label class="radio">
<input type="radio" name="action" value="r" {% if propose_cancel %}checked{% endif %}>
{% trans "Cancel the order irrevocably." %}
</label>
</div>
{% endif %}
<div class="form-group submit-group">
<a class="btn btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">

View File

@@ -27,8 +27,10 @@
{% if request.event.has_subevents %}
<fieldset>
<legend>{% trans "Select date" context "subevents" %}</legend>
{% bootstrap_field form.subevent layout="control" %}
{% bootstrap_field form.all_subevents layout="control" %}
{% bootstrap_field form.subevent layout="control" %}
{% bootstrap_field form.subevents_from layout="control" %}
{% bootstrap_field form.subevents_to layout="control" %}
</fieldset>
{% endif %}
<fieldset>
@@ -39,6 +41,7 @@
{% bootstrap_field form.gift_card_expires layout="control" %}
{% bootstrap_field form.gift_card_conditions layout="control" %}
{% bootstrap_field form.keep_fee_fixed layout="control" %}
{% bootstrap_field form.keep_fee_per_ticket layout="control" %}
{% bootstrap_field form.keep_fee_percentage layout="control" %}
{% bootstrap_field form.keep_fees layout="control" %}
</fieldset>

View File

@@ -2,19 +2,36 @@
{% load bootstrap3 %}
{% if order.status == "n" %}
{% if order.require_approval %}
<span class="label label-warning {{ class }}">{% trans "Approval pending" %}</span>
<span class="label label-warning {{ class }}">
<span class="fa fa-question-circle"></span>
{% trans "Approval pending" %}
</span>
{% else %}
<span data-toggle="tooltip" title="{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}"
class="label label-warning {{ class }}">{% trans "Pending" %}</span>
class="label label-warning {{ class }}">
<span class="fa fa-money"></span>
{% trans "Pending" %}
</span>
{% endif %}
{% elif order.status == "p" %}
{% if order.count_positions == 0 %}
<span class="label label-info {{ class }}">{% trans "Canceled (paid fee)" %}</span>
<span class="label label-info {{ class }}">
<span class="fa fa-times"></span>
{% trans "Canceled (paid fee)" %}
</span>
{% else %}
<span class="label label-success {{ class }}">{% trans "Paid" %}</span>
<span class="label label-success {{ class }}">
<span class="fa fa-check"></span>
{% trans "Paid" %}
</span>
{% endif %}
{% elif order.status == "e" %} {# expired #}
<span class="label label-danger {{ class }}">{% trans "Expired" %}</span>
<span class="label label-danger {{ class }}">
<span class="fa fa-clock-o"></span>
{% trans "Expired" %}</span>
{% elif order.status == "c" %}
<span class="label label-danger {{ class }}">{% trans "Canceled" %}</span>
<span class="label label-danger {{ class }}">
<span class="fa fa-times"></span>
{% trans "Canceled" %}
</span>
{% endif %}

View File

@@ -7,7 +7,7 @@
{% block title %}{% trans "Orders" %}{% endblock %}
{% block content %}
<h1>{% trans "Orders" %}</h1>
{% if not filter_form.filtered and orders|length == 0 %}
{% if not filter_form.filtered and orders|length == 0 and not filter_strings %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
@@ -21,57 +21,72 @@
{% trans "Take your shop live" %}
</a>
{% else %}
<a href="{% eventurl request.event "presale:event.index" %}" class="btn btn-primary btn-lg">
<a href="{% eventurl request.event "presale:event.index" %}" class="btn btn-primary btn-lg" target="_blank">
{% trans "Go to the ticket shop" %}
</a>
{% endif %}
</div>
{% else %}
<div class="row filter-form">
<form class="col-md-2 col-xs-12"
action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}">
<div class="input-group">
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}" autofocus>
<span class="input-group-btn">
<button class="btn btn-primary" type="submit">{% trans "Go!" %}</button>
</span>
</div>
</form>
<form class="" action="" method="get">
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.status layout='inline' %}
</div>
{% if request.event.has_subevents %}
<div class="col-md-1 col-xs-6">
{% bootstrap_field filter_form.item layout='inline' %}
</div>
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.subevent layout='inline' %}
</div>
<div class="col-md-1 col-xs-6">
{% bootstrap_field filter_form.provider layout='inline' %}
</div>
{% else %}
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.item layout='inline' %}
</div>
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.provider layout='inline' %}
</div>
{% endif %}
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.query layout='inline' %}
</div>
<div class="col-md-2 col-xs-6">
<button class="btn btn-primary btn-block" type="submit">
<span class="fa fa-filter"></span>
<span class="hidden-md">
{% trans "Filter" %}
{% if filter_strings %}
<p>
<span class="fa fa-filter"></span>
{% trans "Search query:" %}
{{ filter_strings|join:" · " }}
·
<a href="{% url "control:event.orders.search" event=request.event.slug organizer=request.event.organizer.slug %}?{{ request.META.QUERY_STRING }}">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</p>
{% else %}
<div class="row filter-form">
<form class="col-md-2 col-xs-12"
action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}">
<div class="input-group">
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}" autofocus>
<span class="input-group-btn">
<button class="btn btn-primary" type="submit">{% trans "Go!" %}</button>
</span>
</button>
</div>
</form>
</div>
</div>
</form>
<form class="" action="" method="get">
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.status layout='inline' %}
</div>
{% if request.event.has_subevents %}
<div class="col-md-1 col-xs-6">
{% bootstrap_field filter_form.item layout='inline' %}
</div>
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.subevent layout='inline' %}
</div>
<div class="col-md-1 col-xs-6">
{% bootstrap_field filter_form.provider layout='inline' %}
</div>
{% else %}
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.item layout='inline' %}
</div>
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.provider layout='inline' %}
</div>
{% endif %}
<div class="col-md-2 col-xs-6">
{% bootstrap_field filter_form.query layout='inline' %}
</div>
<div class="col-md-1 col-xs-6">
<button class="btn btn-primary btn-block" type="submit">
<span class="fa fa-filter"></span>
</button>
</div>
<div class="col-md-1 col-xs-6">
<a href="{% url "control:event.orders.search" event=request.event.slug organizer=request.event.organizer.slug %}" class="btn btn-default btn-block" type="submit" data-toggle="tooltip" title="{% trans "Advanced search" %}">
<span class="fa fa-cog"></span>
</a>
</div>
</form>
</div>
{% endif %}
{% if filter_form.is_valid and filter_form.cleaned_data.question %}
<p class="text-muted">
<span class="fa fa-filter"></span>

View File

@@ -12,7 +12,14 @@
<button type="button" data-target=".sum-net" class="btn btn-default">{% trans "Revenue (net)" %}</button>
</div>
</div>
<h1>{% trans "Order overview" %}</h1>
<h1>
{% trans "Order overview" %}
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=pdfreport"
class="btn btn-default" target="_blank">
<span class="fa fa-download"></span>
{% trans "PDF" %}
</a>
</h1>
<div class="row filter-form">
<form class="" action="" method="get">
{% if request.event.has_subevents %}
@@ -58,12 +65,14 @@
<th>{% trans "Product" %}</th>
<th>{% trans "Canceled" %}¹</th>
<th>{% trans "Expired" %}</th>
<th>{% trans "Approval pending" %}</th>
<th colspan="3" class="text-center">{% trans "Purchased" %}</th>
</tr>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th>{% trans "Pending" %}</th>
<th>{% trans "Paid" %}</th>
<th>{% trans "Total" %}</th>
@@ -76,6 +85,7 @@
<th>{{ tup.0 }}</th>
<th>{{ tup.0.num.canceled|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.expired|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.unapproved|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.pending|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.paid|togglesum:request.event.currency }}</th>
<th>{{ tup.0.num.total|togglesum:request.event.currency }}</th>
@@ -95,7 +105,12 @@
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=n&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}&amp;status=pa&amp;provider={{ item.provider }}">
{{ item.num.unapproved|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=na&amp;provider={{ item.provider }}">
{{ item.num.pending|togglesum:request.event.currency }}
</a>
</td>
@@ -123,7 +138,12 @@
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=n&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=pa&amp;provider={{ item.provider }}">
{{ var.num.unapproved|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=na&amp;provider={{ item.provider }}">
{{ var.num.pending|togglesum:request.event.currency }}
</a>
</td>
@@ -146,6 +166,7 @@
<th>{% trans "Total" %}</th>
<th>{{ total.num.canceled|togglesum:request.event.currency }}</th>
<th>{{ total.num.expired|togglesum:request.event.currency }}</th>
<th>{{ total.num.unapproved|togglesum:request.event.currency }}</th>
<th>{{ total.num.pending|togglesum:request.event.currency }}</th>
<th>{{ total.num.paid|togglesum:request.event.currency }}</th>
<th>{{ total.num.total|togglesum:request.event.currency }}</th>

View File

@@ -0,0 +1,23 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load money %}
{% load bootstrap3 %}
{% block title %}{% trans "Order search" %}{% endblock %}
{% block content %}
<h1>{% trans "Order search" %}</h1>
<form class="form-horizontal" action="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}" method="get">
{% for f in forms %}
{% bootstrap_form_errors f layout='control' %}
{% for field in f %}
{% bootstrap_field field layout='control' %}
{% endfor %}
{% endfor %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Search" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "pretixcontrol/auth/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Disable notifications" %}{% endblock %}
{% block content %}
<form action="" method="post" class="form-signin">
{% csrf_token %}
<div class="text-center">
<p>Please confirm that you no longer want to receive notifications for any of your events.</p>
<p><button type="submit" class="btn btn-primary" value="Disable notifications">Disable notifications</button></p>
</div>
</form>
{% endblock %}

View File

@@ -253,6 +253,8 @@ urlpatterns = [
name='event.order.info'),
url(r'^orders/(?P<code>[0-9A-Z]+)/sendmail$', orders.OrderSendMail.as_view(),
name='event.order.sendmail'),
url(r'^orders/(?P<code>[0-9A-Z]+)/(?P<position>[0-9A-Z]+)/sendmail$', orders.OrderPositionSendMail.as_view(),
name='event.order.position.sendmail'),
url(r'^orders/(?P<code>[0-9A-Z]+)/mail_history$', orders.OrderEmailHistory.as_view(),
name='event.order.mail_history'),
url(r'^orders/(?P<code>[0-9A-Z]+)/payments/(?P<payment>\d+)/cancel$', orders.OrderPaymentCancel.as_view(),
@@ -281,6 +283,7 @@ urlpatterns = [
url(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
url(r'^orders/search$', orders.OrderSearch.as_view(), name='event.orders.search'),
url(r'^dangerzone/$', event.DangerZone.as_view(), name='event.dangerzone'),
url(r'^cancel/$', orders.EventCancel.as_view(), name='event.cancel'),
url(r'^shredder/$', shredder.StartShredView.as_view(), name='event.shredder.start'),

View File

@@ -937,6 +937,7 @@ class EventDelete(RecentAuthenticationRequiredMixin, EventPermissionRequiredMixi
data={
'event_id': self.request.event.pk,
'name': str(self.request.event.name),
'slug': self.request.event.slug,
'logentries': list(self.request.event.logentry_set.values_list('pk', flat=True))
}
)

View File

@@ -236,6 +236,10 @@ class EventWizard(SafeSessionWizardView):
event.has_subevents = foundation_data['has_subevents']
event.testmode = True
form_dict['basics'].save()
event.log_action(
'pretix.event.added',
user=self.request.user,
)
if not EventWizardBasicsForm.has_control_rights(self.request.user, event.organizer):
if basics_data["team"] is not None:

View File

@@ -45,7 +45,7 @@ from pretix.base.models import (
from pretix.base.models.orders import (
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
)
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.models.tax import cc_to_vat_prefix, is_eu_country
from pretix.base.payment import PaymentException
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
@@ -74,16 +74,18 @@ from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.mixins import OrderQuestionsViewMixin
from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.filter import (
EventOrderFilterForm, OverviewFilterForm, RefundFilterForm,
EventOrderExpertFilterForm, EventOrderFilterForm, OverviewFilterForm,
RefundFilterForm,
)
from pretix.control.forms.orders import (
CancelForm, CommentForm, ConfirmPaymentForm, EventCancelForm, ExporterForm,
ExtendForm, MarkPaidForm, OrderContactForm, OrderFeeChangeForm,
OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
OrderPositionAddFormset, OrderPositionChangeForm, OrderRefundForm,
OtherOperationsForm,
OrderPositionAddFormset, OrderPositionChangeForm, OrderPositionMailForm,
OrderRefundForm, OtherOperationsForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import order_search_forms
from pretix.control.views import PaginationMixin
from pretix.helpers.safedownload import check_token
from pretix.presale.signals import question_form_fields
@@ -91,7 +93,31 @@ from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__)
class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView):
class OrderSearchMixin:
def get_forms(self):
f = [
EventOrderExpertFilterForm(
data=self.request.GET,
event=self.request.event,
prefix='expert',
)
]
for recv, resp in order_search_forms.send(sender=self.request.event, request=self.request):
f.append(resp)
return f
class OrderSearch(OrderSearchMixin, EventPermissionRequiredMixin, TemplateView):
template_name = 'pretixcontrol/orders/search.html'
permission = 'can_view_orders'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['forms'] = self.get_forms()
return ctx
class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):
model = Order
context_object_name = 'orders'
template_name = 'pretixcontrol/orders/index.html'
@@ -105,12 +131,21 @@ class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView):
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
for f in self.get_forms():
if any(k.startswith(f.prefix) for k in self.request.GET.keys()) and f.is_valid():
qs = f.filter_qs(qs)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
ctx['filter_strings'] = []
for f in self.get_forms():
if any(k.startswith(f.prefix) for k in self.request.GET.keys()) and f.is_valid():
ctx['filter_strings'] += f.filter_to_strings()
# Only compute this annotations for this page (query optimization)
s = OrderPosition.objects.filter(
order=OuterRef('pk')
@@ -566,16 +601,17 @@ class OrderRefundProcess(OrderView):
if self.refund.state == OrderRefund.REFUND_STATE_EXTERNAL:
self.refund.done(user=self.request.user)
if self.request.POST.get("action") == "r" and (self.order.status != Order.STATUS_CANCELED and self.order.positions.exists()):
mark_order_refunded(self.order, user=self.request.user)
elif not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0):
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
now(),
self.order.event.subevents.filter(
id__in=self.order.positions.values_list('subevent_id', flat=True))
)
self.order.save(update_fields=['status', 'expires'])
if self.order.status != Order.STATUS_CANCELED and self.order.positions.exists():
if self.request.POST.get("action") == "r":
mark_order_refunded(self.order, user=self.request.user)
elif not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0):
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
now(),
self.order.event.subevents.filter(
id__in=self.order.positions.values_list('subevent_id', flat=True))
)
self.order.save(update_fields=['status', 'expires'])
messages.success(self.request, _('The refund has been processed.'))
else:
@@ -1130,7 +1166,7 @@ class OrderCheckVATID(OrderView):
messages.error(self.request, _('No country specified.'))
return redirect(self.get_order_url())
if str(ia.country) not in EU_COUNTRIES:
if not is_eu_country(ia.country):
messages.error(self.request, _('VAT ID could not be checked since a non-EU country has been '
'specified.'))
return redirect(self.get_order_url())
@@ -1515,7 +1551,7 @@ class OrderChange(OrderView):
elif change_subevent is not None:
ocm.change_subevent(p, *change_subevent)
if p.form.cleaned_data.get('seat') and (not p.seat or p.form.cleaned_data['seat'] != p.seat.seat_guid):
if p.form.cleaned_data.get('seat') and (not p.seat or p.form.cleaned_data['seat'] != p.seat.seat_guid or change_subevent):
ocm.change_seat(p, p.form.cleaned_data['seat'])
if p.form.cleaned_data['price'] is not None and p.form.cleaned_data['price'] != p.price:
@@ -1783,6 +1819,57 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
return ctx
class OrderPositionSendMail(OrderSendMail):
form_class = OrderPositionMailForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['position'] = get_object_or_404(
OrderPosition,
order__event=self.request.event,
order__code=self.kwargs['code'].upper(),
pk=self.kwargs['position'],
attendee_email__isnull=False
)
return kwargs
def form_valid(self, form):
position = get_object_or_404(
OrderPosition,
order__event=self.request.event,
order__code=self.kwargs['code'].upper(),
pk=self.kwargs['position'],
attendee_email__isnull=False
)
self.preview_output = {}
with language(position.order.locale):
email_context = get_email_context(event=position.order.event, order=position.order, position=position)
email_template = LazyI18nString(form.cleaned_data['message'])
email_subject = str(form.cleaned_data['subject']).format_map(TolerantDict(email_context))
email_content = render_mail(email_template, email_context)
if self.request.POST.get('action') == 'preview':
self.preview_output = {
'subject': _('Subject: {subject}').format(subject=email_subject),
'html': markdown_compile_email(email_content)
}
return self.get(self.request, *self.args, **self.kwargs)
else:
try:
position.send_mail(
form.cleaned_data['subject'],
email_template,
email_context,
'pretix.event.order.position.email.custom_sent',
self.request.user
)
messages.success(self.request,
_('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
except SendMailException:
messages.error(self.request,
_('Failed to send mail to the following user: {}'.format(position.attendee_email)))
return super(OrderSendMail, self).form_valid(form)
class OrderEmailHistory(EventPermissionRequiredMixin, OrderViewMixin, ListView):
template_name = 'pretixcontrol/order/mail_history.html'
permission = 'can_view_orders'
@@ -2023,12 +2110,15 @@ class EventCancel(EventPermissionRequiredMixin, AsyncAction, FormView):
return self.do(
self.request.event.pk,
subevent=form.cleaned_data['subevent'].pk if form.cleaned_data.get('subevent') else None,
subevents_from=form.cleaned_data.get('subevents_from'),
subevents_to=form.cleaned_data.get('subevents_to'),
auto_refund=form.cleaned_data.get('auto_refund'),
manual_refund=form.cleaned_data.get('manual_refund'),
refund_as_giftcard=form.cleaned_data.get('refund_as_giftcard'),
giftcard_expires=form.cleaned_data.get('gift_card_expires'),
giftcard_conditions=form.cleaned_data.get('gift_card_conditions'),
keep_fee_fixed=form.cleaned_data.get('keep_fee_fixed'),
keep_fee_per_ticket=form.cleaned_data.get('keep_fee_per_ticket'),
keep_fee_percentage=form.cleaned_data.get('keep_fee_percentage'),
keep_fees=form.cleaned_data.get('keep_fees'),
send=form.cleaned_data.get('send'),

View File

@@ -1057,7 +1057,7 @@ class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
})
)
try:
r.payment_provider.execute_payment(None, r)
r.payment_provider.execute_payment(request, r)
except PaymentException as e:
with transaction.atomic():
r.state = OrderPayment.PAYMENT_STATE_FAILED
@@ -1254,6 +1254,8 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, V
user=self.request.user.id,
fileid=str(cf.id),
provider=self.exporter.identifier,
device=None,
token=None,
form_data=self.exporter.form.cleaned_data
)

View File

@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset
from django.contrib import messages
from django.core.files import File
from django.db import transaction
from django.db import connections, transaction
from django.db.models import F, IntegerField, OuterRef, Prefetch, Subquery, Sum
from django.db.models.functions import Coalesce
from django.forms import inlineformset_factory
@@ -863,7 +863,13 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
f.subevent = se
f.save()
LogEntry.objects.bulk_create(log_entries)
if connections['default'].features.can_return_rows_from_bulk_insert:
LogEntry.objects.bulk_create(log_entries)
LogEntry.bulk_postprocess(log_entries)
else:
for le in log_entries:
le.save()
LogEntry.bulk_postprocess(log_entries)
self.request.event.cache.clear()
messages.success(self.request, pgettext_lazy('subevent', '{} new dates have been created.').format(len(subevents)))

View File

@@ -22,6 +22,7 @@ from django.views import View
from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_scopes import scopes_disabled
from pretix.base.auth import get_auth_backends
from pretix.base.forms.auth import ReauthForm
@@ -576,7 +577,13 @@ class User2FARegenerateEmergencyView(RecentAuthenticationRequiredMixin, Template
class UserNotificationsDisableView(TemplateView):
def get(self, request, *args, **kwargs):
template_name = 'pretixcontrol/user/notifications_disable.html'
@scopes_disabled()
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
user = get_object_or_404(User, notifications_token=kwargs.get('token'), pk=kwargs.get('id'))
user.notifications_send = False
user.save()

View File

@@ -0,0 +1,43 @@
import os
import re
from configparser import _UNSET
class EnvOrParserConfig:
def __init__(self, configparser):
self.cp = configparser
def _envkey(self, section, option):
section = re.sub('[^a-zA-Z0-9]', '_', section.upper())
option = re.sub('[^a-zA-Z0-9]', '_', option.upper())
return f'PRETIX_{section}_{option}'
def get(self, section, option, *, raw=False, vars=None, fallback=_UNSET):
if self._envkey(section, option) in os.environ:
return os.environ[self._envkey(section, option)]
return self.cp.get(section, option, raw=raw, vars=vars, fallback=fallback)
def getint(self, section, option, *, raw=False, vars=None, fallback=_UNSET):
if self._envkey(section, option) in os.environ:
return int(os.environ[self._envkey(section, option)])
return self.cp.getint(section, option, raw=raw, vars=vars, fallback=fallback)
def getfloat(self, section, option, *, raw=False, vars=None, fallback=_UNSET):
if self._envkey(section, option) in os.environ:
return float(os.environ[self._envkey(section, option)])
return self.cp.getfloat(section, option, raw=raw, vars=vars, fallback=fallback)
def getboolean(self, section, option, *, raw=False, vars=None, fallback=_UNSET):
if self._envkey(section, option) in os.environ:
return self.cp._convert_to_boolean(os.environ[self._envkey(section, option)])
return self.cp.getboolean(section, option, raw=raw, vars=vars, fallback=fallback)
def has_section(self, section):
if any(k.startswith(self._envkey(section, '')) for k in os.environ):
return True
return self.cp.has_section(section)
def has_option(self, section, option):
if self._envkey(section, option) in os.environ:
return True
return self.cp.has_option(section, option)

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: 2020-07-30 19:00+0000\n"
"Last-Translator: Abdullah <abdullah.gumaijan@gmail.com>\n"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: 2020-09-15 02:00+0000\n"
"Last-Translator: Mie Frydensbjerg <mif@aarhus.dk>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: 2020-08-25 02:00+0000\n"
"Last-Translator: Dennis Lichtenthäler <lichtenthaeler@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"

View File

@@ -128,6 +128,7 @@ Leaflet
loszulegen
Ltd
max
Merchandise
Meta
Metadaten
Mi

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: 2020-08-25 02:00+0000\n"
"Last-Translator: Dennis Lichtenthäler <lichtenthaeler@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"

View File

@@ -128,6 +128,7 @@ Leaflet
loszulegen
Ltd
max
Merchandise
Meta
Metadaten
Mi

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: 2019-10-03 19:00+0000\n"
"Last-Translator: Chris Spy <chrispiropoulou@hotmail.com>\n"
"Language-Team: Greek <https://translate.pretix.eu/projects/pretix/pretix-js/"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-24 20:02+0000\n"
"POT-Creation-Date: 2020-11-24 09:10+0000\n"
"PO-Revision-Date: 2020-04-27 20:00+0000\n"
"Last-Translator: Gonzalo Gabriel Perez <zalitoar@gmail.com>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"

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