Compare commits

...

117 Commits

Author SHA1 Message Date
Raphael Michel bb82d013e6 OrderChangeManager: Prevent usage on canceled orders 2023-01-24 18:54:46 +01:00
Raphael Michel 3bc8450d4f Email shredder: Also shred attendee emails and incoming bounces 2023-01-24 18:18:29 +01:00
Raphael Michel fdcad926f9 Changing orders: Default to not notifying the user (#3056) 2023-01-24 16:16:29 +01:00
Raphael Michel 433262f6fc Prepare for DeleteView change in Django 4.0 2023-01-24 14:16:01 +01:00
Fabian 50596b7543 bump debian version (#3055) 2023-01-23 21:37:17 +01:00
Raphael Michel 988188b00a Scheduled exports: Fix missing event context, fix form initial 2023-01-23 11:31:54 +01:00
Raphael Michel fdc15a753c Scheduled exports: Set owner to cc instead of to if there is an explicit recipient (#3045) 2023-01-23 11:10:47 +01:00
Raphael Michel 785cc49a2e Bank transfer: Fix SEPA debits not shown on organizer level 2023-01-20 16:59:25 +01:00
Raphael Michel 863fd3065a Optimize CheckinList.inside_count (#3043) 2023-01-20 16:02:19 +01:00
Raphael Michel ac361a8f47 Scheduled exports: Use proper JSON encoder 2023-01-20 12:59:38 +01:00
Raphael Michel 56d928d5ec Widget: Do not declare products "FREE" if they have mandatory addons (#3041) 2023-01-20 09:15:14 +01:00
Richard Schreiber 6c3e745d5d Control: Remove empty help-text for colorpickers with no-contrast (#3042) 2023-01-20 08:50:08 +01:00
Raphael Michel b29efb9694 Scheduled exports: Add required transaction 2023-01-19 18:41:46 +01:00
Raphael Michel 5ee1213dbf Gift card list export: Use date picker 2023-01-19 17:46:06 +01:00
Raphael Michel c29dc49819 Scheduled exports: Lock exports while setting their new time 2023-01-19 16:31:47 +01:00
Raphael Michel 8b74f791f4 Export schedule: Fix computation of start time on same day 2023-01-19 14:34:27 +01:00
Raphael Michel 4d75438a11 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5081 of 5081 strings)

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

powered by weblate
2023-01-19 13:37:58 +01:00
Raphael Michel 781002b27e Translations: Update German
Currently translated at 100.0% (5081 of 5081 strings)

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

powered by weblate
2023-01-19 13:37:58 +01:00
Raphael Michel f7c0e8c8d0 Translations: Extend German word list 2023-01-19 13:20:14 +01:00
Raphael Michel 70a3516725 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-19 11:47:34 +01:00
Raphael Michel 3133e18b22 Fix isort issue 2023-01-19 11:46:56 +01:00
Raphael Michel 3257c59117 Delete checkins when deleting orders 2023-01-19 11:46:51 +01:00
Raphael Michel 19d1a8de71 Scheduled exports (#3033) 2023-01-19 11:46:30 +01:00
Richard Schreiber 0bb5af191b Product list: Fix add-to-cart-button being shown on seating-only event (#3038) 2023-01-19 10:56:48 +01:00
Raphael Michel 8fe56b7278 Export: Fix date range validation 2023-01-19 10:51:18 +01:00
Martin Gross df432b1958 Presale: Set "Contact Event Organizer"-mailto href to _blank 2023-01-18 17:29:18 +01:00
Raphael Michel 54434f07a9 Email settings: Order languages of preview like form 2023-01-18 12:23:43 +01:00
Raphael Michel 0ecbee48ae Stripe: Catch failing promise on JS level 2023-01-18 11:47:28 +01:00
Maurice Kaag ff2fa43ba1 Translations: Update French
Currently translated at 63.3% (128 of 202 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/fr/

powered by weblate
2023-01-18 11:39:23 +01:00
Maurice Kaag 3a1cefbbe7 Translations: Update French
Currently translated at 48.9% (2464 of 5029 strings)

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

powered by weblate
2023-01-18 11:39:23 +01:00
Raphael Michel 7aa433e9af Translations: Update French
Currently translated at 46.8% (2354 of 5029 strings)

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

powered by weblate
2023-01-18 11:39:23 +01:00
Maurice Kaag c5a5d13158 Translations: Update French
Currently translated at 62.8% (127 of 202 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/fr/

powered by weblate
2023-01-18 11:39:23 +01:00
Maurice Kaag 2e256e30be Translations: Update French
Currently translated at 46.8% (2354 of 5029 strings)

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

powered by weblate
2023-01-18 11:39:23 +01:00
Raphael Michel 0fbc0c3ffb Refresh order status after applying gift card 2023-01-17 15:26:31 +01:00
Richard Schreiber 93950d3fac Presale: separate multiple lines by comma in ical event location (#3037) 2023-01-17 13:17:58 +01:00
pretix translation bot e8269ed1bf Update translations (#3031)
Co-authored-by: Raphael Michel <michel@rami.io>
2023-01-13 15:43:06 +01:00
Raphael Michel 3fa1fbf6e2 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-13 15:07:59 +01:00
Raphael Michel 8114b47c8c API: Support for date ranges in exports 2023-01-13 13:48:45 +01:00
Raphael Michel dcf5e67196 Fix minor issues in DateFrameField/DateFrameWidget 2023-01-13 13:30:12 +01:00
Raphael Michel bf4569b080 Exports: Add predefined timeframes (#3027)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-13 13:14:08 +01:00
Aurélia BOUYGE 95979143d7 Translations: Update French
Currently translated at 45.7% (2284 of 4995 strings)

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

powered by weblate
2023-01-13 11:20:00 +01:00
tree 4c5e77c2ef Translations: Update Czech
Currently translated at 85.1% (172 of 202 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/cs/

powered by weblate
2023-01-13 11:20:00 +01:00
tree 95b4f08aeb Translations: Update Czech
Currently translated at 26.7% (1336 of 4995 strings)

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

powered by weblate
2023-01-13 11:20:00 +01:00
Raphael Michel d6605e668b Bump Pillow to 9.4.* 2023-01-13 11:19:21 +01:00
Raphael Michel 6ee348548f Bump django-compressor to 4.3.* 2023-01-13 11:18:39 +01:00
Raphael Michel fca8e48f6a Bump arabic-reshaper to 3.0.0 2023-01-13 11:18:20 +01:00
Raphael Michel 5a295934f7 Update select2 from 4.0.6-rc.1 to 4.0.13 2023-01-13 10:49:54 +01:00
Raphael Michel 4385b41e8b Item typeahead: Allow search by internal name 2023-01-13 10:40:39 +01:00
Raphael Michel 92dacfb966 Only run new validation on newly uploaded files 2023-01-12 19:00:40 +01:00
Raphael Michel d1acbad181 Export: Fix issue showing error messages 2023-01-12 18:06:23 +01:00
Richard Schreiber d0676765a4 Checkout: remove Indonesia from zipcode-validation (#3029) 2023-01-12 16:57:13 +01:00
Raphael Michel 9dd3b12625 Validate image size in pixels at upload time (#3003) 2023-01-12 16:30:28 +01:00
Raphael Michel 738301d2af CI: Fix syntax error 2023-01-12 15:13:58 +01:00
Raphael Michel f7f29e8a55 Do not read language from session any more (deprecated since Django 3.0) 2023-01-12 15:00:37 +01:00
Raphael Michel ad69ec293f CI: Use own codecov upload token to prevent rate limit issue (#3028) 2023-01-12 13:36:13 +01:00
Raphael Michel 3443296a28 Device list: Hide revoked devices by default (#2996) 2023-01-12 13:35:43 +01:00
Richard Schreiber 7a69e00d39 Control: improve settings-icon for non-personalized tickets 2023-01-12 11:00:43 +01:00
Raphael Michel bddc91d595 Export: Fix handling of form validation errors 2023-01-12 09:56:45 +01:00
Raphael Michel 0c0d8b2c55 ItemDataExporter: Fix off-by-one 2023-01-12 09:45:17 +01:00
Raphael Michel c018921a18 Translate label of JSONExporter 2023-01-11 15:10:14 +01:00
Raphael Michel f33aa3fdba Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael Michel 7b55f85663 Translations: Update German
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael Michel fb9909ca83 Translations: Update German
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael Michel 35e8bab7a5 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael Michel bf34e73121 Translations: Update German
Currently translated at 99.9% (4994 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael Michel 39e2715f3c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael Michel 97d2b015cf Translations: Update German
Currently translated at 99.9% (4994 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael Michel ca30a07da3 Update translation wordlists 2023-01-11 13:48:22 +01:00
Raphael Michel 81d31ce64c Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-11 13:07:47 +01:00
Raphael Michel 0ae66ab7f6 Reorganize UI for exporters (#3025)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-11 12:34:56 +01:00
Raphael Michel cb4af51c01 Sendmail: Fix issue loading old logs 2023-01-10 17:36:53 +01:00
Raphael Michel 6b44cae607 Fix incorrect handling of admission/personalized in API PATCH 2023-01-10 17:28:58 +01:00
Raphael Michel 1a4d4029c9 Sendmail: Fix incorrect placeholder promoted for waiting list 2023-01-10 13:28:22 +01:00
Raphael Michel 3563653d55 Payment step: Fix edge case when redeeming gift cards with service fees 2023-01-10 13:17:27 +01:00
Raphael Michel e4c9afa87a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4961 of 4961 strings)

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

powered by weblate
2023-01-10 12:34:37 +01:00
Raphael Michel 6938397a6a Translations: Update German
Currently translated at 100.0% (4961 of 4961 strings)

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

powered by weblate
2023-01-10 12:34:37 +01:00
Raphael Michel 24e5b593ea Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-10 12:04:50 +01:00
Raphael Michel cd237d4c19 Sendmail: Improve wording 2023-01-10 12:04:03 +01:00
Raphael Michel 9b1d7cc522 Sendmail: Abstract away to allow more types of recipients (#2994)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-10 12:03:50 +01:00
Raphael Michel d07948613a Validate tax rates to be between 0 and 100 2023-01-10 11:48:42 +01:00
Raphael Michel eadc1b4812 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael Michel 787d4ec06b Translations: Update German
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Richard Schreiber ca1d13421f Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Richard Schreiber 495ae25b9e Translations: Update German
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael Michel d98accdd2d Translations: Update German
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael Michel 746ced9e93 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael Michel d72bbffc51 Translations: Update German
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael Michel 8503623472 Add word to German wordlist 2023-01-09 15:32:36 +01:00
Raphael Michel 4f097e279a Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-09 14:58:25 +01:00
Raphael Michel 603225d042 Separate personalization from admission (#2990)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-09 14:57:35 +01:00
Raphael Michel e5528f7784 Writable API for ticket layouts (#3004)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-09 13:44:01 +01:00
Raphael Michel 2e702b87de Do not show empty invoice address fields on confirmation 2023-01-09 11:24:30 +01:00
Raphael Michel 59730ff501 Stripe: Rename SOFORT's public name 2023-01-09 10:03:37 +01:00
Raphael Michel 280c24528f API: Fix crash when creating item variations with require_membership_types 2023-01-09 10:03:19 +01:00
Raphael Michel ff09ed422c Prevent requiring a membership without selecting any types 2023-01-06 23:17:00 +01:00
Raphael Michel b3be64b9f3 Bank transfer: Small parser improvement 2023-01-05 09:41:35 +01:00
Raphael Michel 018c3d70e3 API: Allow to set order of check-in lists 2023-01-04 18:29:35 +01:00
Raphael Michel a2f2d25169 Allow users with can_checkin_orders permission to use the bulk actions of the check-in list view 2023-01-04 18:13:24 +01:00
Raphael Michel 4747a4c480 Order change view: Remove a few buttons for read-only users 2023-01-04 18:13:24 +01:00
dependabot[bot] ed9a9246e3 Bump @babel/core from 7.20.5 to 7.20.7 in /src/pretix/static/npm_dir (#3002)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-02 11:42:41 +01:00
Raphael Michel 6e63d34932 Cart: Prevent TypeError mixing seated and unseated lines 2023-01-02 10:33:51 +01:00
Raphael Michel db06ed132a PPv2: Fix invalid cart payments in edge case (PRETIXEU-7QG) 2023-01-02 10:19:31 +01:00
Raphael Michel ddbe38ca53 API: Do not crash if invalid data type is given for name_parts 2023-01-02 10:17:09 +01:00
Raphael Michel d3698b3e2f Widget: Annotate parts of widget source code 2022-12-22 11:36:22 +01:00
Fazenda Dengo ff828ecc92 Translations: Update Portuguese (Portugal)
Currently translated at 85.6% (4228 of 4934 strings)

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

powered by weblate
2022-12-22 10:47:53 +01:00
Fazenda Dengo d0236572f0 Translations: Update Portuguese (Brazil)
Currently translated at 12.9% (637 of 4934 strings)

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

powered by weblate
2022-12-22 10:47:53 +01:00
Raphael Michel 8ea6f3bc7d Fix #2499 -- Incorrect type detection order in RelativeDateWrapper 2022-12-21 15:34:31 +01:00
Raphael Michel 5587aebcd8 Fix failing widget tests 2022-12-21 15:02:36 +01:00
Raphael Michel 0b708067de Widget: Support for seated waiting list 2022-12-21 12:17:32 +01:00
Raphael Michel e75dc74661 Allow consecutive password resets 2022-12-21 10:01:25 +01:00
Julian Rother f0c5e54e34 PPv2: Fix continue button behaviour (#2821)
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2022-12-20 16:09:42 +01:00
Raphael Michel eeb6e11934 PPv2: Revert format change of log entries 2022-12-20 16:01:44 +01:00
Raphael Michel 1238165e7a PPv2: Handle types correctly in webhook 2022-12-20 15:29:37 +01:00
Raphael Michel bcf65603e4 Enable database health checks for when we use Django 4.1
4.1 is a while in the future but I really don't want to forget this then
as it is so useful!
2022-12-20 14:54:31 +01:00
Raphael Michel c4aed04a18 PPv2: Fix incomplete validation of capture status 2022-12-20 14:54:31 +01:00
Raphael Michel 8be09ee937 Bump dnspython to 2.2.* 2022-12-20 14:54:31 +01:00
Richard Schreiber a1ec45daf6 Fix addon typo (#2987) 2022-12-20 09:15:38 +01:00
254 changed files with 132630 additions and 92859 deletions
+1
View File
@@ -81,5 +81,6 @@ jobs:
uses: codecov/codecov-action@v1
with:
file: src/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
if: matrix.database == 'postgres' && matrix.python-version == '3.10'
+1 -1
View File
@@ -12,7 +12,7 @@ solution with many things readily set-up, look at :ref:`dockersmallscale`.
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
offers at `pretix.eu`_.
We tested this guide on the Linux distribution **Debian 10.0** but it should work very similar on other
We tested this guide on the Linux distribution **Debian 11.6** but it should work very similar on other
modern distributions, especially on all systemd-based ones.
Requirements
+3
View File
@@ -192,6 +192,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
specifiers in requests
(see below).
Date range *either* two dates separated ``2022-03-18/2022-03-23``, ``2022-03-18/``,
by ``/`` *or* the name of a ``/2022-03-23``, ``week_this``, ``week_next``,
defined range. ``month_this``
===================== ============================ ===================================
Query parameters
+2
View File
@@ -98,6 +98,8 @@ Endpoints
:query string ends_after: Exclude all check-in lists attached to a sub-event that is already in the past at the given time.
:query string expand: Expand a field into a full object. Currently only ``subevent`` is supported. Can be passed multiple times.
:query string exclude: Exclude a field from the output, e.g. ``checkin_count``. Can be used as a performance optimization. Can be passed multiple times.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``name``, and ``subevent__date_from``,
Default: ``subevent__date_from,name``
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
+12 -1
View File
@@ -35,6 +35,12 @@ tax_rule integer The internal ID
admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others
(such as add-ons or merchandise).
personalized boolean ``true`` for items that require personalization according
to event settings. Only affects system-level fields, not
custom questions. Currently only allowed for products with
``admission`` set to ``true``. For backwards compatibility,
when creating new items and this field is not given, it defaults
to the same value as ``admission``.
position integer An integer, used for sorting
picture file A product picture to be displayed in the shop
(can be ``null``).
@@ -158,7 +164,7 @@ meta_data object Values set for
.. versionchanged:: 4.16
The ``variations[x].meta_data`` attribute has been added.
The ``variations[x].meta_data`` attribute has been added. The ``personalized`` attribute has been added.
Notes
-----
@@ -213,6 +219,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -329,6 +336,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -426,6 +434,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -510,6 +519,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -626,6 +636,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
+4
View File
@@ -76,6 +76,10 @@ The exporter class
This is an abstract attribute, you **must** override this!
.. autoattribute:: description
.. autoattribute:: category
.. autoattribute:: export_form_fields
.. automethod:: render
+127 -4
View File
@@ -17,9 +17,13 @@ Field Type Description
id integer Internal layout ID
name string Internal layout description
default boolean ``true`` if this is the default layout
layout object Layout specification for libpretixprint
layout list Dynamic layout specification. Each list element
corresponds to one dynamic element of the layout.
The current version of the schema in use can be found
`here`_.
Submitting invalid content can lead to application errors.
background URL Background PDF file
item_assignments list of objects Products this layout is assigned to
item_assignments list of objects Products this layout is assigned to (currently read-only)
├ sales_channel string Sales channel (defaults to ``web``).
└ item integer Item ID
===================================== ========================== =======================================================
@@ -58,7 +62,7 @@ Endpoints
"name": "Default layout",
"default": true,
"layout": {…},
"background": {},
"background": null,
"item_assignments": []
}
]
@@ -96,7 +100,7 @@ Endpoints
"name": "Default layout",
"default": true,
"layout": {…},
"background": {},
"background": null,
"item_assignments": []
}
@@ -147,3 +151,122 @@ Endpoints
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/
Creates a new ticket layout
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "Default layout",
"default": true,
"layout": […],
"background": null,
"item_assignments": []
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Default layout",
"default": true,
"layout": […],
"background": null,
"item_assignments": []
}
:param organizer: The ``slug`` field of the organizer of the event to create a layout for
:param event: The ``slug`` field of the event to create a layout for
:statuscode 201: no error
:statuscode 400: The layout could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
Update a layout. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"name": "Default layout"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Default layout",
"default": true,
"layout": […],
"background": null,
"item_assignments": []
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the layout to modify
:statuscode 200: no error
:statuscode 400: The layout could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
Delete a layout.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the layout to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json
+1
View File
@@ -97,6 +97,7 @@ overpayment
param
passphrase
percental
personalization
pluggable
positionid
pre
+43
View File
@@ -22,8 +22,10 @@
from django import forms
from django.http import QueryDict
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
class FormFieldWrapperField(serializers.Field):
@@ -142,6 +144,12 @@ class JobRunSerializer(serializers.Serializer):
allow_null=not v.required,
validators=v.validators,
)
elif isinstance(v, DateFrameField):
self.fields[k] = SerializerDateFrameField(
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)
@@ -151,5 +159,40 @@ class JobRunSerializer(serializers.Serializer):
for k, v in self.fields.items():
if isinstance(v, serializers.ManyRelatedField) and k not in data:
data[k] = []
for fk in self.fields.keys():
# Backwards compatibility for exports that used to take e.g. (date_from, date_to) or (event_date_from, event_date_to)
# and now only take date_range.
if fk.endswith("_range") and isinstance(self.fields[fk], SerializerDateFrameField) and fk not in data:
if fk.replace("_range", "_from") in data:
d_from = data.pop(fk.replace("_range", "_from"))
if d_from:
d_from = serializers.DateField().to_internal_value(d_from)
else:
d_from = None
if fk.replace("_range", "_to") in data:
d_to = data.pop(fk.replace("_range", "_to"))
if d_to:
d_to = serializers.DateField().to_internal_value(d_to)
else:
d_to = None
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
data = super().to_internal_value(data)
return data
def is_valid(self, raise_exception=False):
super().is_valid(raise_exception=raise_exception)
fields_keys = set(self.fields.keys())
input_keys = set(self.initial_data.keys())
additional_fields = input_keys - fields_keys
if bool(additional_fields):
self._errors['fields'] = ['Additional fields not allowed: {}.'.format(list(additional_fields))]
if self._errors and raise_exception:
raise ValidationError(self.errors)
return not bool(self._errors)
+14 -1
View File
@@ -95,8 +95,12 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
require_membership_types = validated_data.pop('require_membership_types', [])
variation = ItemVariation.objects.create(**validated_data)
if require_membership_types:
variation.require_membership_types.add(*require_membership_types)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
@@ -230,7 +234,7 @@ class ItemSerializer(I18nAwareModelSerializer):
class Meta:
model = Item
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission', 'personalized',
'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
@@ -258,6 +262,15 @@ class ItemSerializer(I18nAwareModelSerializer):
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until'))
if data.get('personalized') and not data.get('admission'):
raise ValidationError(_('Only admission products can currently be personalized.'))
if data.get('admission') and 'personalized' not in data and not self.instance:
# Backwards compatibility
data['personalized'] = True
elif 'admission' in data and not data['admission']:
data['personalized'] = False
if data.get('issue_giftcard'):
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
raise ValidationError(
+8
View File
@@ -118,6 +118,10 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'name': ['Do not specify name if you specified name_parts.']}
)
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
raise ValidationError({'name_parts': ['Invalid data type']})
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
@@ -841,6 +845,10 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and not isinstance(data.get('attendee_name_parts'), dict):
raise ValidationError({'attendee_name_parts': ['Invalid data type']})
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
+4 -2
View File
@@ -158,12 +158,14 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
if not answ_data.get('answer'):
continue
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
if isinstance(answ_data.get('answer'), File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
@@ -173,7 +175,7 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
if isinstance(answ_data.get('answer'), File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
+7
View File
@@ -79,6 +79,13 @@ class CustomerSerializer(I18nAwareModelSerializer):
validated_data['external_identifier'] = instance.external_identifier
return super().update(instance, validated_data)
def validate(self, data):
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
raise ValidationError({'name_parts': ['Invalid data type']})
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme
return data
class CustomerCreateSerializer(CustomerSerializer):
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
+3 -1
View File
@@ -93,8 +93,10 @@ with scopes_disabled():
class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer
queryset = CheckinList.objects.none()
filter_backends = (DjangoFilterBackend,)
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filterset_class = CheckinListFilter
ordering = ('subevent__date_from', 'name')
ordering_fields = ('subevent__date_from', 'id', 'name',)
def _get_permission_name(self, request):
if request.path.endswith('/failed_checkins/'):
+11
View File
@@ -21,6 +21,7 @@
#
import datetime
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.timezone import now
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework.authentication import SessionAuthentication
@@ -33,6 +34,9 @@ from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
from pretix.api.auth.token import TeamTokenAuthentication
from pretix.base.models import CachedFile
from pretix.helpers.images import (
IMAGE_TYPES, validate_uploaded_file_for_valid_image,
)
ALLOWED_TYPES = {
'image/gif': {'.gif'},
@@ -61,6 +65,13 @@ class UploadView(APIView):
name=file_obj.name,
type=content_type
))
if content_type in IMAGE_TYPES:
try:
validate_uploaded_file_for_valid_image(file_obj)
except DjangoValidationError as e:
raise ValidationError(e.message)
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
date=now(),
+2 -4
View File
@@ -42,7 +42,6 @@ from localflavor.fr.forms import FRZipCodeField
from localflavor.gb.forms import GBPostcodeField
from localflavor.gr.forms import GRPostalCodeField
from localflavor.hr.forms import HRPostalCodeField
from localflavor.id_.forms import IDPostCodeField
from localflavor.ie.forms import EircodeField
from localflavor.il.forms import ILPostalCodeField
from localflavor.in_.forms import INZipCodeField
@@ -80,8 +79,8 @@ COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED = {
# We don't presume this for countries we don't have knowledge about, there are countries in the
# world e.g. without zipcodes
'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
'GB', 'GR', 'HR', 'ID', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX',
'NL', 'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
'GB', 'GR', 'HR', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX', 'NL',
'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
}
@@ -167,7 +166,6 @@ _zip_code_fields = {
'GB': GBPostcodeField,
'GR': GRPostalCodeField,
'HR': HRPostalCodeField,
'ID': IDPostCodeField,
'IE': EircodeField,
'IL': ILPostalCodeField,
'IN': INZipCodeField,
+7 -7
View File
@@ -520,20 +520,20 @@ def base_placeholders(sender, **kwargs):
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
),
SimpleFunctionalMailTextPlaceholder(
'url_remove', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: build_absolute_uri(
'url_remove', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.waitinglist.remove'
) + '?voucher=' + waiting_list_entry.voucher.code,
) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.waitinglist.remove',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalMailTextPlaceholder(
'url', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: build_absolute_uri(
'url', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + waiting_list_entry.voucher.code,
) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.redeem',
@@ -588,7 +588,7 @@ def base_placeholders(sender, **kwargs):
_('Sample Admission Ticket')
),
SimpleFunctionalMailTextPlaceholder(
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
'68CYU2H6ZTP3WLK5'
),
SimpleFunctionalMailTextPlaceholder(
+22 -1
View File
@@ -36,7 +36,7 @@ import io
import tempfile
from collections import OrderedDict, namedtuple
from decimal import Decimal
from typing import Tuple
from typing import Optional, Tuple
import pytz
from defusedcsv import csv
@@ -84,6 +84,27 @@ class BaseExporter:
"""
raise NotImplementedError() # NOQA
@property
def description(self) -> str:
"""
A description for this exporter.
"""
return ""
@property
def category(self) -> Optional[str]:
"""
A category name for this exporter, or ``None``.
"""
return None
@property
def featured(self) -> bool:
"""
If ``True``, this exporter will be highlighted.
"""
return False
@property
def identifier(self) -> str:
"""
+5 -2
View File
@@ -39,7 +39,7 @@ from zipfile import ZipFile
from django import forms
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import QuestionAnswer
@@ -49,7 +49,10 @@ from ..signals import register_data_exporters
class AnswerFilesExporter(BaseExporter):
identifier = 'answerfiles'
verbose_name = _('Answers to file upload questions')
verbose_name = _('Question answer file uploads')
category = pgettext_lazy('export_category', 'Order data')
description = _('Download a ZIP file including all files that have been uploaded by your customers while creating '
'an order.')
@property
def export_form_fields(self):
+3 -1
View File
@@ -36,7 +36,7 @@ from collections import OrderedDict
from django.dispatch import receiver
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -48,6 +48,8 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts')
organizer_required_permission = 'can_manage_customers'
category = pgettext_lazy('export_category', 'Customer accounts')
description = gettext_lazy('Download a spreadsheet of all currently registered customer accounts.')
@property
def additional_form_fields(self):
+17 -28
View File
@@ -23,22 +23,24 @@ import json
from collections import OrderedDict
from decimal import Decimal
import dateutil
from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.dispatch import receiver
from django.utils.translation import gettext, gettext_lazy
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
from pretix.base.i18n import language
from pretix.base.models import Invoice, OrderPayment
from ..exporter import BaseExporter
from ..signals import register_data_exporters
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
class DekodiNREIExporter(BaseExporter):
identifier = 'dekodi_nrei'
verbose_name = 'dekodi NREI (JSON)'
category = pgettext_lazy('export_category', 'Invoices')
description = gettext_lazy("Download invoices in a format that can be used by the dekodi NREI conversion software.")
# Specification: http://manuals.dekodi.de/nexuspub/schnittstellenbuch/
@@ -113,7 +115,7 @@ class DekodiNREIExporter(BaseExporter):
'PTNo14': p.info_data.get('reference') or '',
'PTNo15': p.full_id or '',
})
elif p.provider.startswith('stripe'):
elif p.provider and p.provider.startswith('stripe'):
src = p.info_data.get("source", p.info_data)
payments.append({
'PTID': '81',
@@ -192,17 +194,12 @@ class DekodiNREIExporter(BaseExporter):
def render(self, form_data):
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__gte=date_value)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__lte=date_value)
if form_data.get('date_range'):
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
if d_start:
qs = qs.filter(date__gte=d_start)
if d_end:
qs = qs.filter(date__lte=d_end)
jo = {
'Format': 'NREI',
@@ -218,22 +215,14 @@ class DekodiNREIExporter(BaseExporter):
def export_form_fields(self):
return OrderedDict(
[
('date_from',
forms.DateField(
label=gettext_lazy('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
('date_range',
DateFrameField(
label=gettext_lazy('Date range'),
include_future_frames=False,
required=False,
help_text=gettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
help_text=gettext_lazy('Only include invoices issued in this time frame. Note that the invoice date does '
'not always correspond to the order or payment date.')
)),
('date_to',
forms.DateField(
label=gettext_lazy('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=gettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
'does not always correspond to the order or payment date.')
)),
]
)
+3 -1
View File
@@ -35,7 +35,7 @@
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from ...control.forms.filter import get_all_payment_providers
from ..exporter import ListExporter
@@ -45,6 +45,8 @@ from ..signals import register_multievent_data_exporters
class EventDataExporter(ListExporter):
identifier = 'eventdata'
verbose_name = _('Event data')
category = pgettext_lazy('export_category', 'Event data')
description = _('Download a spreadsheet with information on all events in this organizer account.')
@cached_property
def providers(self):
+22 -25
View File
@@ -38,13 +38,15 @@ from collections import OrderedDict
from decimal import Decimal
from zipfile import ZipFile
import dateutil.parser
from django import forms
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import gettext, gettext_lazy as _, pgettext
from django.utils.timezone import now
from django.utils.translation import (
gettext, gettext_lazy as _, pgettext, pgettext_lazy,
)
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
@@ -57,30 +59,24 @@ from ..services.invoices import invoice_pdf_task
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
class InvoiceExporterMixin:
category = pgettext_lazy('export_category', 'Invoices')
@property
def invoice_exporter_form_fields(self):
return OrderedDict(
[
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
('date_range',
DateFrameField(
label=_('Date range'),
include_future_frames=False,
required=False,
help_text=_('Only include invoices issued on or after this date. Note that the invoice date does '
help_text=_('Only include invoices issued in this time frame. Note that the invoice date does '
'not always correspond to the order or payment date.')
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include invoices issued on or before this date. Note that the invoice date '
'does not always correspond to the order or payment date.')
)),
('payment_provider',
forms.ChoiceField(
label=_('Payment provider'),
@@ -112,16 +108,12 @@ class InvoiceExporterMixin:
)
)
qs = qs.filter(has_payment_with_provider=1)
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__gte=date_value)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__lte=date_value)
if form_data.get('date_range'):
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
if d_start:
qs = qs.filter(date__gte=d_start)
if d_end:
qs = qs.filter(date__lte=d_end)
return qs
@@ -129,6 +121,7 @@ class InvoiceExporterMixin:
class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
identifier = 'invoices'
verbose_name = _('All invoices')
description = _('Download all invoices created by the system as a ZIP file of PDF files.')
def render(self, form_data: dict, output_file=None):
qs = self.invoices_queryset(form_data).filter(shredded=False)
@@ -180,6 +173,10 @@ class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
identifier = 'invoicedata'
verbose_name = _('Invoice data')
description = _('Download a spreadsheet with the data of all invoices created by the system. The spreadsheet '
'includes two sheets, one with a line for every invoice, and one with a line for every position of '
'every invoice.')
featured = True
@property
def additional_form_fields(self):
+6 -1
View File
@@ -22,7 +22,7 @@
from django.db.models import Prefetch
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from openpyxl.styles import Alignment
from openpyxl.utils import get_column_letter
@@ -48,6 +48,8 @@ def _min(a1, a2):
class ItemDataExporter(ListExporter):
identifier = 'itemdata'
verbose_name = _('Product data')
category = pgettext_lazy('export_category', 'Product data')
description = _('Download a spreadsheet with details about all products and variations.')
def iterate_list(self, form_data):
locales = self.event.settings.locales
@@ -73,6 +75,7 @@ class ItemDataExporter(ListExporter):
_("Free price input"),
_("Sales tax"),
_("Is an admission ticket"),
_("Personalized ticket"),
_("Generate tickets"),
_("Waiting list"),
_("Available from"),
@@ -144,6 +147,7 @@ class ItemDataExporter(ListExporter):
_("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission else "",
_("Yes") if i.personalized else "",
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
_("Yes") if i.allow_waitinglist else "",
date_format(_max(i.available_from, v.available_from).astimezone(self.timezone),
@@ -187,6 +191,7 @@ class ItemDataExporter(ListExporter):
_("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission else "",
_("Yes") if i.personalized else "",
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
_("Yes") if i.allow_waitinglist else "",
date_format(i.available_from.astimezone(self.timezone),
+7 -1
View File
@@ -38,6 +38,8 @@ from decimal import Decimal
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Prefetch
from django.dispatch import receiver
from django.utils.functional import lazy
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
from ..exporter import BaseExporter
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
@@ -46,7 +48,10 @@ from ..signals import register_data_exporters
class JSONExporter(BaseExporter):
identifier = 'json'
verbose_name = 'Order data (JSON)'
verbose_name = lazy(lambda *args: gettext('Order data') + ' (JSON)', str)()
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a structured JSON representation of all orders. This might be useful for the '
'import in third-party systems.')
def render(self, form_data):
jo = {
@@ -78,6 +83,7 @@ class JSONExporter(BaseExporter):
'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'),
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
'admission': item.admission,
'personalized': item.personalized,
'active': item.active,
'sales_channels': item.sales_channels,
'description': str(item.description),
+3 -1
View File
@@ -36,7 +36,7 @@ from collections import OrderedDict
from django import forms
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import OrderPosition
@@ -50,6 +50,8 @@ from ..signals import (
class MailExporter(BaseExporter):
identifier = 'mailaddrs'
verbose_name = _('Email addresses (text file)')
category = pgettext_lazy('export_category', 'Order data')
description = _("Download a text file with all email addresses collected either from buyers or from ticket holders.")
def render(self, form_data: dict):
qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event')
+78 -104
View File
@@ -33,10 +33,8 @@
# License for the specific language governing permissions and limitations under the License.
from collections import OrderedDict
from datetime import date, datetime, time
from decimal import Decimal
import dateutil
import pytz
from django import forms
from django.db.models import (
@@ -46,8 +44,10 @@ from django.db.models import (
from django.db.models.functions import Coalesce
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext as _, gettext_lazy, pgettext
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
)
from pretix.base.models import (
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
@@ -63,14 +63,24 @@ from ...helpers.iter import chunked_iterable
from ..exporter import (
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
)
from ..forms.widgets import SplitDateTimePickerWidget
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
from ..timeframes import (
DateFrameField,
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
)
class OrderListExporter(MultiSheetListExporter):
identifier = 'orderlist'
verbose_name = gettext_lazy('Order data')
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a spreadsheet of all orders. The spreadsheet will include three sheets, one '
'with a line for every order, one with a line for every order position, and one with '
'a line for every additional fee charged in an order.')
featured = True
@cached_property
def providers(self):
@@ -105,41 +115,25 @@ class OrderListExporter(MultiSheetListExporter):
initial=False,
required=False
)),
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
('date_range',
DateFrameField(
label=_('Date range'),
include_future_frames=False,
required=False,
help_text=_('Only include orders created on or after this date.')
help_text=_('Only include orders created within this date range.')
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
('event_date_range',
DateFrameField(
label=_('Event date'),
include_future_frames=True,
required=False,
help_text=_('Only include orders created on or before this date.')
)),
('event_date_from',
forms.DateField(
label=_('Start event date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
'Will also include other dates in case of mixed orders!')
)),
('event_date_to',
forms.DateField(
label=_('End event date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders including at least one ticket for a date on or before this date. '
help_text=_('Only include orders including at least one ticket for a date in this range. '
'Will also include other dates in case of mixed orders!')
)),
]
d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents:
del d['event_date_from']
del d['event_date_to']
del d['event_date_range']
return d
def _get_all_payment_methods(self, qs):
@@ -182,45 +176,27 @@ class OrderListExporter(MultiSheetListExporter):
annotations = {}
filters = {}
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if not isinstance(date_value, date):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
if form_data.get('date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
if dt_start:
filters[f'{rel}datetime__gte'] = dt_start
if dt_end:
filters[f'{rel}datetime__lt'] = dt_end
filters[f'{rel}datetime__gte'] = datetime_value
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if not isinstance(date_value, date):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
filters[f'{rel}datetime__lte'] = datetime_value
if form_data.get('event_date_from'):
date_value = form_data.get('event_date_from')
if not isinstance(date_value, date):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
annotations['event_date_max'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_max__gte'] = datetime_value
if form_data.get('event_date_to'):
date_value = form_data.get('event_date_to')
if not isinstance(date_value, date):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
annotations['event_date_min'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_min__lte'] = datetime_value
if form_data.get('event_date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['event_date_range'], self.timezone)
if dt_start:
annotations['event_date_max'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_max__gte'] = dt_start
if dt_end:
annotations['event_date_min'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_min__lt'] = dt_end
if filters:
return qs.annotate(**annotations).filter(**filters)
@@ -776,7 +752,10 @@ class OrderListExporter(MultiSheetListExporter):
class PaymentListExporter(ListExporter):
identifier = 'paymentlist'
verbose_name = gettext_lazy('Order payments and refunds')
verbose_name = gettext_lazy('Payments and refunds')
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a spreadsheet of all payments or refunds of every order.')
featured = True
@property
def additional_form_fields(self):
@@ -855,6 +834,8 @@ class PaymentListExporter(ListExporter):
class QuotaListExporter(ListExporter):
identifier = 'quotalist'
verbose_name = gettext_lazy('Quota availabilities')
category = pgettext_lazy('export_category', 'Product data')
description = gettext_lazy('Download a spreadsheet of all quotas including their current availability.')
def iterate_list(self, form_data):
has_subevents = self.event.has_subevents
@@ -908,21 +889,17 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions')
organizer_required_permission = 'can_manage_gift_cards'
category = pgettext_lazy('export_category', 'Gift cards')
description = gettext_lazy('Download a spreadsheet of all gift card transactions.')
@property
def additional_form_fields(self):
d = [
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
('date_range',
DateFrameField(
label=_('Date range'),
include_future_frames=False,
required=False
)),
]
d = OrderedDict(d)
@@ -933,22 +910,12 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
card__issuer=self.organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
)
if form_data.get('date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
if dt_start:
qs = qs.filter(datetime__gte=dt_start)
if dt_end:
qs = qs.filter(datetime__lt=dt_end)
headers = [
_('Gift card code'),
@@ -978,6 +945,8 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
class GiftcardRedemptionListExporter(ListExporter):
identifier = 'giftcardredemptionlist'
verbose_name = gettext_lazy('Gift card redemptions')
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a spreadsheet of all payments or refunds that involve gift cards.')
def iterate_list(self, form_data):
payments = OrderPayment.objects.filter(
@@ -1023,14 +992,18 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards')
organizer_required_permission = 'can_manage_gift_cards'
category = pgettext_lazy('export_category', 'Gift cards')
description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.')
@property
def additional_form_fields(self):
return OrderedDict(
[
('date', forms.DateTimeField(
('date', forms.SplitDateTimeField(
label=_('Show value at'),
initial=now(),
required=False,
widget=SplitDateTimePickerWidget(),
help_text=_('Defaults to the time of report.')
)),
('testmode', forms.ChoiceField(
label=_('Test mode'),
@@ -1058,12 +1031,13 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
)
def iterate_list(self, form_data):
d = form_data.get('date') or now()
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'),
datetime__lte=form_data['date']
datetime__lte=d
).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = self.organizer.issued_gift_cards.filter(
issuance__lte=form_data['date']
issuance__lte=d
).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related(
@@ -1078,11 +1052,11 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value':
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=d))
elif form_data.get('state') == 'expired_value':
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
qs = qs.exclude(cached_value=0).filter(expires__lt=d)
elif form_data.get('state') == 'expired':
qs = qs.filter(expires__lt=form_data['date'])
qs = qs.filter(expires__lt=d)
headers = [
_('Gift card code'),
+2
View File
@@ -39,6 +39,8 @@ from ..signals import (
class WaitingListExporter(ListExporter):
identifier = 'waitinglist'
verbose_name = _('Waiting list')
category = pgettext_lazy('export_category', 'Waiting list')
description = _('Download a spread sheet with all your waiting list data.')
# map selected status to label and queryset-filter
status_filters = [
+5 -5
View File
@@ -531,7 +531,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
code='aspect_ratio_not_3_by_4',
)
except Exception as exc:
logger.exception('foo')
logger.exception('Could not parse image')
# Pillow doesn't recognize it as an image.
if isinstance(exc, ValidationError):
raise
@@ -575,7 +575,7 @@ class BaseQuestionsForm(forms.Form):
add_fields = {}
if item.admission and event.settings.attendee_names_asked:
if item.ask_attendee_data and event.settings.attendee_names_asked:
add_fields['attendee_name_parts'] = NamePartsFormField(
max_length=255,
required=event.settings.attendee_names_required and not self.all_optional,
@@ -584,7 +584,7 @@ class BaseQuestionsForm(forms.Form):
label=_('Attendee name'),
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
)
if item.admission and event.settings.attendee_emails_asked:
if item.ask_attendee_data and event.settings.attendee_emails_asked:
add_fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required and not self.all_optional,
label=_('Attendee email'),
@@ -595,7 +595,7 @@ class BaseQuestionsForm(forms.Form):
}
)
)
if item.admission and event.settings.attendee_company_asked:
if item.ask_attendee_data and event.settings.attendee_company_asked:
add_fields['company'] = forms.CharField(
required=event.settings.attendee_company_required and not self.all_optional,
label=_('Company'),
@@ -603,7 +603,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.company if cartpos else orderpos.company),
)
if item.admission and event.settings.attendee_addresses_asked:
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
add_fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'),
+3 -9
View File
@@ -30,7 +30,6 @@ from django.urls import get_script_prefix
from django.utils import timezone, translation
from django.utils.cache import patch_vary_headers
from django.utils.deprecation import MiddlewareMixin
from django.utils.translation import LANGUAGE_SESSION_KEY
from django.utils.translation.trans_real import (
check_for_language, get_supported_language_variant, language_code_re,
parse_accept_lang_header,
@@ -128,12 +127,7 @@ def get_language_from_user_settings(request: HttpRequest) -> str:
return lang_code
def get_language_from_session_or_cookie(request: HttpRequest) -> str:
if hasattr(request, 'session'):
lang_code = request.session.get(LANGUAGE_SESSION_KEY)
if lang_code in _supported and lang_code is not None and check_for_language(lang_code):
return lang_code
def get_language_from_cookie(request: HttpRequest) -> str:
lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
try:
return get_supported_language_variant(lang_code)
@@ -187,14 +181,14 @@ def get_language_from_request(request: HttpRequest) -> str:
return (
get_language_from_user_settings(request)
or get_language_from_customer_settings(request)
or get_language_from_session_or_cookie(request)
or get_language_from_cookie(request)
or get_language_from_browser(request)
or get_language_from_event(request)
or get_default_language()
)
else:
return (
get_language_from_session_or_cookie(request)
get_language_from_cookie(request)
or get_language_from_customer_settings(request)
or get_language_from_user_settings(request)
or get_language_from_browser(request)
@@ -0,0 +1,27 @@
# Generated by Django 3.2.16 on 2022-12-21 08:59
from django.db import migrations, models
def item_set_personalized(apps, schema_editor):
# We cannot really know if a position was bundled or an add-on, but we can at least guess
Item = apps.get_model("pretixbase", "Item")
Item.objects.filter(admission=True).update(personalized=True)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0226_itemvariationmetavalue'),
]
operations = [
migrations.AddField(
model_name='item',
name='personalized',
field=models.BooleanField(default=False),
),
migrations.RunPython(
item_set_personalized,
migrations.RunPython.noop,
),
]
@@ -0,0 +1,68 @@
# Generated by Django 3.2.16 on 2023-01-18 11:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0227_item_personalized'),
]
operations = [
migrations.CreateModel(
name='ScheduledOrganizerExport',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('export_identifier', models.CharField(max_length=190)),
('export_form_data', models.JSONField(default=dict)),
('locale', models.CharField(max_length=250)),
('mail_additional_recipients', models.TextField()),
('mail_additional_recipients_cc', models.TextField()),
('mail_additional_recipients_bcc', models.TextField()),
('mail_subject', models.CharField(max_length=250)),
('mail_template', models.TextField()),
('schedule_rrule', models.TextField(null=True)),
('schedule_rrule_time', models.TimeField()),
('schedule_next_run', models.DateTimeField(blank=True, null=True)),
('error_counter', models.IntegerField(default=0)),
('error_last_message', models.TextField(null=True)),
('timezone', models.CharField(default='UTC', max_length=100)),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.organizer')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='ScheduledEventExport',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('export_identifier', models.CharField(max_length=190)),
('export_form_data', models.JSONField(default=dict)),
('locale', models.CharField(max_length=250)),
('mail_additional_recipients', models.TextField()),
('mail_additional_recipients_cc', models.TextField()),
('mail_additional_recipients_bcc', models.TextField()),
('mail_subject', models.CharField(max_length=250)),
('mail_template', models.TextField()),
('schedule_rrule', models.TextField(null=True)),
('schedule_rrule_time', models.TimeField()),
('schedule_next_run', models.DateTimeField(blank=True, null=True)),
('error_counter', models.IntegerField(default=0)),
('error_last_message', models.TextField(null=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.event')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
]
+1
View File
@@ -30,6 +30,7 @@ from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
SubEvent, SubEventMetaValue, generate_invite_token,
)
from .exports import ScheduledEventExport, ScheduledOrganizerExport
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
+53 -3
View File
@@ -35,14 +35,17 @@ from datetime import timedelta
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
from django.db import connection, models
from django.db.models import (
Count, Exists, F, Max, OuterRef, Q, Subquery, Value, Window,
)
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.helpers import PostgresWindowFrame
class CheckinList(LoggedModel):
@@ -140,7 +143,54 @@ class CheckinList(LoggedModel):
@property
def inside_count(self):
return self.positions_inside.count()
if "postgresql" not in settings.DATABASES["default"]["ENGINE"]:
# Use the simple query that works on all databases
return self.positions_inside.count()
# Use the PostgreSQL-specific query using Window functions, which is a lot faster.
# On a real-world example with ~100k tickets, of which ~17k are checked in, we observed
# a speed-up from 29s (old) to a few hundred milliseconds (new)!
# Why is this so much faster? The regular query get's PostgreSQL all busy with filtering
# the tickets both by their belonging the event and checkin status at the same time, while
# this query just iterates over all successful checkins on the list, and -- by the power
# of window functions -- asks "is this an entry that is followed by no exit?". Then we
# dedupliate by position and count it up.
cl = self
base_q, base_params = (
Checkin.all.filter(successful=True, position__in=cl.positions, list=cl)
.annotate(
cnt_exists_after=Window(
expression=Count("position_id", filter=Q(type=Value("exit"))),
partition_by=[F("position_id"), F("list_id")],
order_by=F("datetime").asc(),
frame=PostgresWindowFrame(
"ROWS", start="1 following", end="unbounded following"
),
)
)
.values("position_id", "type", "datetime", "cnt_exists_after")
.query.sql_with_params()
)
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT COUNT(*) FROM (
SELECT COUNT("position_id")
FROM ({str(base_q)} ) s
WHERE "type" = %s AND "cnt_exists_after" = 0
GROUP BY "position_id"
) a;
""",
[
*base_params,
"entry",
],
)
rows = cursor.fetchall()
if rows:
return rows[0][0]
return 0
@property
@scopes_disabled()
+2
View File
@@ -632,6 +632,7 @@ class Event(EventMixin, LoggedModel):
return super().presale_has_ended
def delete_all_orders(self, really=False):
from .checkin import Checkin
from .orders import (
OrderFee, OrderPayment, OrderPosition, OrderRefund, Transaction,
)
@@ -645,6 +646,7 @@ class Event(EventMixin, LoggedModel):
OrderFee.objects.filter(order__event=self).delete()
OrderRefund.objects.filter(order__event=self).delete()
OrderPayment.objects.filter(order__event=self).delete()
Checkin.objects.filter(list__event=self).delete()
self.orders.all().delete()
def save(self, *args, **kwargs):
+139
View File
@@ -0,0 +1,139 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from datetime import datetime, timedelta
import pytz
from dateutil.rrule import rrulestr
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext_lazy as _
from pretix.base.models import LoggedModel
from pretix.base.validators import RRuleValidator, multimail_validate
class AbstractScheduledExport(LoggedModel):
id = models.BigAutoField(primary_key=True)
export_identifier = models.CharField(
max_length=190,
verbose_name=_("Export"),
)
export_form_data = models.JSONField(
default=dict,
encoder=DjangoJSONEncoder,
)
owner = models.ForeignKey(
"pretixbase.User",
on_delete=models.PROTECT,
)
locale = models.CharField(
verbose_name=_('Language'),
max_length=250
)
mail_additional_recipients = models.TextField(
verbose_name=_('Additional recipients'),
null=False, blank=True, validators=[multimail_validate],
help_text=_("You can specify multiple recipients separated by commas.")
)
mail_additional_recipients_cc = models.TextField(
verbose_name=_('Additional recipients (Cc)'),
null=False, blank=True, validators=[multimail_validate],
help_text=_("You can specify multiple recipients separated by commas.")
)
mail_additional_recipients_bcc = models.TextField(
verbose_name=_('Additional recipients (Bcc)'),
null=False, blank=True, validators=[multimail_validate],
help_text=_("You can specify multiple recipients separated by commas.")
)
mail_subject = models.CharField(
verbose_name=_('Subject'),
max_length=250
)
mail_template = models.TextField(
verbose_name=_('Message'),
)
schedule_rrule = models.TextField(
null=True, blank=True, validators=[RRuleValidator()]
)
schedule_rrule_time = models.TimeField(
verbose_name=_("Requested start time"),
help_text=_("The actual start time might be delayed depending on system load."),
)
schedule_next_run = models.DateTimeField(null=True, blank=True)
error_counter = models.IntegerField(default=0)
error_last_message = models.TextField(null=True, blank=True)
class Meta:
abstract = True
def __str__(self):
return self.mail_subject
def compute_next_run(self):
tz = self.tz
r = rrulestr(self.schedule_rrule)
base_dt = now().astimezone(tz).replace(tzinfo=None)
if now().astimezone(tz).time() < self.schedule_rrule_time:
base_dt -= timedelta(days=1)
new_d = r.after(base_dt, inc=False)
if not new_d:
self.schedule_next_run = None
return
try:
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz)
except pytz.exceptions.AmbiguousTimeError:
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz, is_dst=False)
except pytz.exceptions.NonExistentTimeError:
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time) + timedelta(hours=1), tz)
class ScheduledEventExport(AbstractScheduledExport):
event = models.ForeignKey(
"pretixbase.Event", on_delete=models.CASCADE, related_name="scheduled_exports"
)
@property
def tz(self):
return self.event.timezone
class ScheduledOrganizerExport(AbstractScheduledExport):
organizer = models.ForeignKey(
"pretixbase.Organizer", on_delete=models.CASCADE, related_name="scheduled_exports"
)
timezone = models.CharField(max_length=100,
default=settings.TIME_ZONE,
verbose_name=_('Timezone'))
@property
def tz(self):
return pytz.timezone(self.timezone)
+17 -3
View File
@@ -62,6 +62,7 @@ from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice
from ...helpers.images import ImageSizeValidator
from .event import Event, SubEvent
@@ -310,6 +311,8 @@ class Item(LoggedModel):
:type tax_rate: decimal.Decimal
:param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise)
:type admission: bool
:param personalized: ``True``, if attendee information should be collected for this ticket
:type personalized: bool
:param picture: A product picture to be shown next to the product description
:type picture: File
:param available_from: The date this product goes on sale
@@ -396,8 +399,14 @@ class Item(LoggedModel):
admission = models.BooleanField(
verbose_name=_("Is an admission ticket"),
help_text=_(
'Whether or not buying this product allows a person to enter '
'your event'
'Whether or not buying this product allows a person to enter your event'
),
default=False
)
personalized = models.BooleanField(
verbose_name=_("Is a personalized ticket"),
help_text=_(
'Whether or not buying this product allows to enter attendee information'
),
default=False
)
@@ -421,7 +430,8 @@ class Item(LoggedModel):
picture = models.ImageField(
verbose_name=_("Product picture"),
null=True, blank=True, max_length=255,
upload_to=itempicture_upload_to
upload_to=itempicture_upload_to,
validators=[ImageSizeValidator()]
)
available_from = models.DateTimeField(
verbose_name=_("Available from"),
@@ -578,6 +588,10 @@ class Item(LoggedModel):
return self.event.settings.show_quota_left
return self.show_quota_left
@property
def ask_attendee_data(self):
return self.admission and self.personalized
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
price = price if price is not None else self.default_price
+2 -2
View File
@@ -808,7 +808,7 @@ class Order(LockModel, LoggedModel):
return True
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions:
if (cp.item.admission and ask_names) or cp.item.questions.all():
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
return True
return False # nothing there to modify
@@ -2688,7 +2688,7 @@ class CartPosition(AbstractPosition):
category_key = (self.item.category.position, self.item.category.id) if self.item.category_id is not None else (0, 0)
item_key = self.item.position, self.item_id
variation_key = (self.variation.position, self.variation.id) if self.variation_id is not None else (0, 0)
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else None), self.pk)
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else 0), self.pk)
sort_key = subevent_key + category_key + item_key + variation_key + line_key
if self.addon_to_id:
+10 -1
View File
@@ -23,6 +23,7 @@ import json
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _, pgettext
@@ -149,7 +150,15 @@ class TaxRule(LoggedModel):
rate = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name=_("Tax rate")
validators=[
MaxValueValidator(
limit_value=Decimal("100.00"),
),
MinValueValidator(
limit_value=Decimal("0.00"),
),
],
verbose_name=_("Tax rate"),
)
price_includes_tax = models.BooleanField(
verbose_name=_("The configured product prices include the tax amount"),
+72 -10
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from datetime import timedelta
from typing import Any, Dict, Union
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models, transaction
@@ -27,14 +28,16 @@ from django.db.models import F, Q, Sum
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from i18nfield.strings import LazyI18nString
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import Voucher
from pretix.base.services.mail import mail
from pretix.base.models import User, Voucher
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.base.settings import PERSON_NAME_SCHEMES
from ...helpers.format import format_map
from .base import LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation
@@ -213,15 +216,74 @@ class WaitingListEntry(LoggedModel):
self.voucher = v
self.save()
self.send_mail(
self.event.settings.mail_subject_waiting_list,
self.event.settings.mail_text_waiting_list,
get_email_context(
event=self.event,
waiting_list_entry=self,
waiting_list_voucher=v,
event_or_subevent=self.subevent or self.event,
),
user=user,
auth=auth,
)
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.waitinglist.email.sent',
user: User=None, headers: dict=None, sender: str=None, auth=None, auto_email=True,
attach_other_files: list=None, attach_cached_files: list=None):
"""
Sends an email to the entry's contact address.
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, and ``recipient``
parameters.
* Create a ``LogEntry`` with the email contents.
:param subject: Subject of the email
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
:param context: Dictionary to use for rendering the template
:param log_entry_type: Key to be used for the log entry
:param user: Administrative user who triggered this mail to be sent
:param headers: Dictionary with additional mail headers
:param sender: Custom email sender.
"""
if not self.email:
return
for k, v in self.event.meta_data.items():
context['meta_' + k] = v
with language(self.locale, self.event.settings.region):
mail(
self.email,
self.event.settings.mail_subject_waiting_list,
self.event.settings.mail_text_waiting_list,
get_email_context(event=self.event, waiting_list_entry=self),
self.event,
locale=self.locale
)
recipient = self.email
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event,
self.locale,
headers=headers,
sender=sender,
auto_email=auto_email,
attach_other_files=attach_other_files,
attach_cached_files=attach_cached_files,
)
except SendMailException:
raise
else:
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
}
)
@staticmethod
def clean_itemvar(event, item, variation):
+25 -2
View File
@@ -35,6 +35,7 @@
import copy
import hashlib
import itertools
import json
import logging
import os
import re
@@ -46,12 +47,15 @@ from collections import OrderedDict
from functools import partial
from io import BytesIO
import jsonschema
from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db.models import Max, Min
from django.dispatch import receiver
from django.utils.deconstruct import deconstructible
from django.utils.formats import date_format
from django.utils.functional import SimpleLazyObject
from django.utils.html import conditional_escape
@@ -740,9 +744,9 @@ class Renderer:
if o['content'] == 'other' or o['content'] == 'other_i18n':
if o['content'] == 'other_i18n':
text = str(LazyI18nString(o['text_i18n']))
text = str(LazyI18nString(o.get('text_i18n', {})))
else:
text = o['text']
text = o.get('text', '')
def replace(x):
if x.group(1).startswith('itemmeta:'):
@@ -975,3 +979,22 @@ class Renderer:
output.write(outbuffer)
outbuffer.seek(0)
return outbuffer
@deconstructible
class PdfLayoutValidator:
def __call__(self, value):
if not isinstance(value, dict):
try:
val = json.loads(value)
except ValueError:
raise ValidationError(_('Your layout file is not a valid JSON file.'))
else:
val = value
with open(finders.find('schema/pdf-layout.schema.json'), 'r') as f:
schema = json.loads(f.read())
try:
jsonschema.validate(val, schema)
except jsonschema.ValidationError as e:
e = str(e).replace('%', '%%')
raise ValidationError(_('Your layout file is not a valid layout. Error message: {}').format(e))
+3 -3
View File
@@ -59,10 +59,10 @@ class RelativeDateWrapper:
def date(self, event) -> datetime.date:
from .models import SubEvent
if isinstance(self.data, datetime.date):
return self.data
elif isinstance(self.data, datetime.datetime):
if isinstance(self.data, datetime.datetime):
return self.data.date()
elif isinstance(self.data, datetime.date):
return self.data
else:
if self.data.minutes_before is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
+1 -1
View File
@@ -140,7 +140,7 @@ error_messages = {
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
'product %(base)s.'),
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another product.'),
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
'seat_required': _('You need to select a specific seat.'),
'seat_invalid': _('Please select a valid seat.'),
+235 -10
View File
@@ -19,31 +19,49 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from typing import Any, Dict
import logging
from datetime import timedelta
from typing import Any, Dict, Union
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils.timezone import override
from django.db import connection, transaction
from django.dispatch import receiver
from django.utils.timezone import now, override
from django.utils.translation import gettext
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.email import get_email_context
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import (
CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name,
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
User, cachedfile_name,
)
from pretix.base.models.exports import ScheduledOrganizerExport
from pretix.base.services.mail import mail
from pretix.base.services.tasks import (
ProfiledEventTask, ProfiledOrganizerUserTask,
EventTask, OrganizerTask, ProfiledEventTask, ProfiledOrganizerUserTask,
)
from pretix.base.signals import (
register_data_exporters, register_multievent_data_exporters,
periodic_task, register_data_exporters, register_multievent_data_exporters,
)
from pretix.celery_app import app
from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__)
class ExportError(LazyLocaleException):
pass
class ExportEmptyError(ExportError):
pass
@app.task(base=ProfiledEventTask, throws=(ExportError,), bind=True)
def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
def set_progress(val):
@@ -56,7 +74,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
file = CachedFile.objects.get(id=fileid)
with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
responses = register_data_exporters.send(event)
for receiver, response in responses:
for recv, response in responses:
if not response:
continue
ex = response(event, event.organizer, set_progress)
@@ -106,16 +124,16 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
timezone = organizer.settings.timezone or settings.TIME_ZONE
region = organizer.settings.region
with language(locale, region), override(timezone):
if form_data.get('events') is not None:
if form_data.get('events') is not None and not form_data.get('all_events'):
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'))
events = allowed_events.filter(pk__in=form_data.get('events'), organizer=organizer)
else:
events = allowed_events
events = allowed_events.filter(organizer=organizer)
responses = register_multievent_data_exporters.send(organizer)
for receiver, response in responses:
for recv, response in responses:
if not response:
continue
ex = response(events, organizer, set_progress)
@@ -138,3 +156,210 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
f = ContentFile(data)
file.file.save(cachedfile_name(file, file.filename), f)
return file.pk
def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, config_url, retry_func, has_permission):
with language(schedule.locale, context.settings.region), override(schedule.tz):
file = CachedFile(web_download=False)
file.date = now()
file.expires = now() + timedelta(hours=24)
file.save()
def _handle_error(msg, soft=False):
context.log_action(
'pretix.event.export.schedule.failed',
data={
'id': schedule.id,
'export_identifier': schedule.export_identifier,
'export_form_data': schedule.export_form_data,
'reason': msg,
'soft': soft,
}
)
if schedule.owner.is_active:
mail(
email=schedule.owner.email,
subject=gettext('Export failed'),
template='pretixbase/email/export_failed.txt',
context={
'configuration_url': config_url,
'reason': msg,
'soft': soft,
},
event=context if isinstance(context, Event) else None,
organizer=context.organizer if isinstance(context, Event) else context,
locale=schedule.locale,
)
if not soft:
schedule.error_counter += 1
schedule.error_last_message = msg
schedule.save(update_fields=['error_counter', 'error_last_message'])
if not has_permission:
_handle_error(gettext('Permission denied.'))
return
try:
if not exporter:
raise ExportError("Export type not found.")
d = exporter.render(schedule.export_form_data)
if d is None:
raise ExportEmptyError(
gettext('Your export did not contain any data.')
)
file.filename, file.type, data = d
filesize = len(data)
if filesize > 20 * 1024 * 1024: # 20 MB
raise ExportError(
gettext('Your exported data exceeded the size limit for scheduled exports.')
)
f = ContentFile(data)
file.file.save(cachedfile_name(file, file.filename), f)
except ExportEmptyError as e:
_handle_error(str(e), soft=True)
except ExportError as e:
_handle_error(str(e), soft=False)
except Exception:
logger.exception("Scheduled export failed.")
try:
retry_func()
except MaxRetriesExceededError:
_handle_error('Internal Error')
else:
schedule.error_counter = 0
schedule.save(update_fields=['error_counter'])
to = [r for r in schedule.mail_additional_recipients.split(",") if r]
cc = [r for r in schedule.mail_additional_recipients_cc.split(",") if r]
bcc = [r for r in schedule.mail_additional_recipients_bcc.split(",") if r]
if to:
# If there is an explicit To, the owner is Cc. Otherwise, the owner is To. Yes, this is
# purely cosmetical and has policital reasons.
cc.append(schedule.owner.email)
else:
to.append(schedule.owner.email)
mail(
email=to,
cc=cc,
bcc=bcc,
subject=schedule.mail_subject,
template=LazyI18nString(schedule.mail_template),
context=get_email_context(event=context) if isinstance(context, Event) else {},
event=context if isinstance(context, Event) else None,
organizer=context.organizer if isinstance(context, Event) else context,
locale=schedule.locale,
attach_cached_files=[file],
)
context.log_action(
'pretix.event.export.schedule.executed',
data={
'id': schedule.id,
'export_identifier': schedule.export_identifier,
'export_form_data': schedule.export_form_data,
'result_file_size': filesize,
'result_file_name': file.file.name,
}
)
@app.task(base=OrganizerTask, bind=True, max_retries=5, default_retry_delay=120)
def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None:
schedule = organizer.scheduled_exports.get(pk=schedule)
allowed_events = schedule.owner.get_events_with_permission('can_view_orders')
if schedule.export_form_data.get('events') is not None and not schedule.export_form_data.get('all_events'):
if isinstance(schedule.export_form_data['events'][0], str):
events = allowed_events.filter(slug__in=schedule.export_form_data.get('events'), organizer=organizer)
else:
events = allowed_events.filter(pk__in=schedule.export_form_data.get('events'), organizer=organizer)
else:
events = allowed_events.filter(organizer=organizer)
responses = register_multievent_data_exporters.send(organizer)
exporter = None
for recv, response in responses:
if not response:
continue
ex = response(events, organizer)
if ex.identifier == schedule.export_identifier:
exporter = ex
break
has_permission = schedule.owner.is_active
if isinstance(exporter, OrganizerLevelExportMixin):
if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission):
has_permission = False
_run_scheduled_export(
schedule,
organizer,
exporter,
build_absolute_uri(
'control:organizer.export',
kwargs={
'organizer': organizer.slug,
}
) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}',
self.retry,
has_permission,
)
@app.task(base=EventTask, bind=True, max_retries=5, default_retry_delay=120)
def scheduled_event_export(self, event: Event, schedule: int) -> None:
schedule = event.scheduled_exports.get(pk=schedule)
responses = register_data_exporters.send(event)
exporter = None
for recv, response in responses:
if not response:
continue
ex = response(event, event.organizer)
if ex.identifier == schedule.export_identifier:
exporter = ex
break
has_permission = schedule.owner.is_active and schedule.owner.has_event_permission(event.organizer, event, 'can_view_orders')
_run_scheduled_export(
schedule,
event,
exporter,
build_absolute_uri(
'control:event.orders.export',
kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}
) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}',
self.retry,
has_permission,
)
@receiver(signal=periodic_task)
@scopes_disabled()
@transaction.atomic
def run_scheduled_exports(sender, **kwargs):
qs = ScheduledEventExport.objects.filter(
schedule_next_run__lt=now(),
error_counter__lt=5,
).select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked).select_related('event')
for s in qs:
scheduled_event_export.apply_async(kwargs={
'event': s.event_id,
'schedule': s.pk,
})
s.compute_next_run()
s.save(update_fields=['schedule_next_run'])
qs = ScheduledOrganizerExport.objects.filter(
schedule_next_run__lt=now(),
error_counter__lt=5,
).select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked).select_related('organizer')
for s in qs:
scheduled_organizer_export.apply_async(kwargs={
'organizer': s.organizer_id,
'schedule': s.pk,
})
s.compute_next_run()
s.save(update_fields=['schedule_next_run'])
+5 -4
View File
@@ -100,7 +100,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None,
plain_text_only=False, no_order_links=False):
plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -211,7 +211,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900]
signature = ""
bcc = []
bcc = list(bcc or [])
settings_holder = event or organizer
@@ -305,6 +305,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email),
cc=cc,
bcc=bcc,
subject=subject,
body=body_plain,
@@ -357,11 +358,11 @@ class CustomEmail(EmailMultiAlternatives):
@app.task(base=TransactionAwareTask, bind=True, acks_late=True)
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
event: int = None, position: int = None, headers: dict = None, cc: List[str] = None, bcc: List[str] = None,
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
attach_other_files: List[str] = None) -> bool:
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
email = CustomEmail(subject, body, sender, to=to, cc=cc, bcc=bcc, headers=headers)
if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
html_with_cid, cid_images = replace_images_with_cid_paths(html)
+6
View File
@@ -1068,6 +1068,8 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
any_payment_failed = True
except Exception:
logger.exception('Error during payment attempt')
else:
order.refresh_from_db()
pending_sum = order.pending_sum
free_order_flow = (
@@ -1341,6 +1343,7 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
class OrderChangeManager:
error_messages = {
'order_canceled': _('You cannot change a canceled order.'),
'product_without_variation': _('You need to select a variation of the product.'),
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
'quota_missing': _('There is no quota defined that allows this operation.'),
@@ -1388,6 +1391,9 @@ class OrderChangeManager:
self._invoice_dirty = False
self._invoices = []
if order.status == Order.STATUS_CANCELED:
raise OrderError(self.error_messages['order_canceled'])
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation'])
+25
View File
@@ -109,6 +109,31 @@ class EventTask(app.Task):
return ret
class OrganizerTask(app.Task):
def __call__(self, *args, **kwargs):
if 'organizer_id' in kwargs:
organizer_id = kwargs.get('organizer_id')
with scopes_disabled():
organizer = Organizer.objects.get(pk=organizer_id)
del kwargs['organizer_id']
kwargs['organizer'] = organizer
elif 'organizer' in kwargs:
organizer_id = kwargs.get('organizer')
with scopes_disabled():
organizer = Organizer.objects.get(pk=organizer_id)
kwargs['organizer'] = organizer
else:
args = list(args)
organizer_id = args[0]
with scopes_disabled():
organizer = Organizer.objects.get(pk=organizer_id)
args[0] = organizer
with scope(organizer=organizer):
ret = super().__call__(*args, **kwargs)
return ret
class OrganizerUserTask(app.Task):
def __call__(self, *args, **kwargs):
organizer_id = kwargs['organizer']
+5 -5
View File
@@ -206,7 +206,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for attendee names"),
help_text=_("Ask for a name for all tickets which include admission to the event."),
help_text=_("Ask for a name for all personalized tickets."),
)
},
'attendee_names_required': {
@@ -229,10 +229,10 @@ DEFAULTS = {
label=_("Ask for email addresses per ticket"),
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
"only to that email address. If you enable this option, the system will additionally ask for "
"individual email addresses for every admission ticket. This might be useful if you want to "
"individual email addresses for every personalized ticket. This might be useful if you want to "
"obtain individual addresses for every attendee even in case of group orders. However, "
"pretix will send the order confirmation by default only to the one primary email address, not to "
"the per-attendee addresses. You can however enable this in the E-mail settings."),
"the per-attendee addresses. You can however enable this in the email settings."),
)
},
'attendee_emails_required': {
@@ -242,7 +242,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require email addresses per ticket"),
help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the "
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
"above option for more details. One email address for the order confirmation will always be "
"required regardless of this setting."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
@@ -2574,7 +2574,7 @@ Your {organizer} team"""))
label=_("Attendee data explanation"),
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown above the questions asked for every admission product. You can use it e.g. to explain "
help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain "
"why you need information from them.")
)
},
+4 -2
View File
@@ -218,8 +218,10 @@ class EmailAddressShredder(BaseDataShredder):
o.meta_info = json.dumps(d)
o.save(update_fields=['meta_info', 'email', 'customer'])
for le in self.event.logentry_set.filter(action_type__contains="order.email"):
shred_log_fields(le, banlist=['recipient', 'message', 'subject'])
for le in self.event.logentry_set.filter(
Q(action_type__contains="order.email") | Q(action_type__contains="position.email"),
):
shred_log_fields(le, banlist=['recipient', 'message', 'subject', 'full_mail'])
for le in self.event.logentry_set.filter(action_type="pretix.event.order.contact.changed"):
shred_log_fields(le, banlist=['old_email', 'new_email'])
@@ -0,0 +1,12 @@
{% load i18n %}
{% trans "Your export failed." %}
{% trans "Reason:" %} {{ reason }}
{% if not soft %}
{% trans "If your export fails five times in a row, it will no longer be sent." %}
{% endif %}
{% trans "Configuration link:" %}
{{ configuration_url }}
@@ -0,0 +1,18 @@
{% load i18n %}
{% with widget.subwidgets.0 as widget %}
{% include widget.template_name %}
{% endwith %}
<div class="row" data-display-dependency-value="custom" data-display-dependency="#{{ widget.subwidgets.0.attrs.id }}">
<br>
<div class="col-sm-6">
{% with widget.subwidgets.1 as widget %}
{% include widget.template_name %}
{% endwith %}
</div>
<div class="col-sm-6">
{% with widget.subwidgets.2 as widget %}
{% include widget.template_name %}
{% endwith %}
</div>
</div>
{% spaceless %}{% for widget in widget.subwidgets %}{% endfor %}{% endspaceless %}
+433
View File
@@ -0,0 +1,433 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import calendar
from datetime import date, datetime, time, timedelta
from itertools import groupby
from typing import Optional, Tuple
import pytz
from django import forms
from django.core.exceptions import ValidationError
from django.utils.formats import date_format
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext_lazy, pgettext_lazy
from rest_framework import serializers
from pretix.helpers.daterange import daterange
def _quarter_start(ref_d):
return ref_d.replace(day=1, month=1 + (ref_d.month - 1) // 3 * 3)
def _week_start(ref_d):
return ref_d - timedelta(ref_d.weekday())
REPORTING_DATE_TIMEFRAMES = (
# (identifier, label, start_inclusive, end_inclusive, includes_future, optgroup, describe)
(
'days_today',
pgettext_lazy('reporting_timeframe', 'Today'),
lambda ref_d: ref_d,
lambda ref_d, start_d: start_d,
False,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_yesterday',
pgettext_lazy('reporting_timeframe', 'Yesterday'),
lambda ref_d: ref_d - timedelta(days=1),
lambda ref_d, start_d: start_d,
False,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_last7',
pgettext_lazy('reporting_timeframe', 'Last 7 days'),
lambda ref_d: ref_d - timedelta(days=6),
lambda ref_d, start_d: start_d + timedelta(days=6),
False,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_last14',
pgettext_lazy('reporting_timeframe', 'Last 14 days'),
lambda ref_d: ref_d - timedelta(days=13),
lambda ref_d, start_d: start_d + timedelta(days=13),
False,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_tomorrow',
pgettext_lazy('reporting_timeframe', 'Tomorrow'),
lambda ref_d: ref_d + timedelta(days=1),
lambda ref_d, start_d: start_d,
True,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_next7',
pgettext_lazy('reporting_timeframe', 'Next 7 days'),
lambda ref_d: ref_d + timedelta(days=1),
lambda ref_d, start_d: start_d + timedelta(days=6),
True,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_next14',
pgettext_lazy('reporting_timeframe', 'Next 14 days'),
lambda ref_d: ref_d + timedelta(days=1),
lambda ref_d, start_d: start_d + timedelta(days=13),
True,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'week_this',
pgettext_lazy('reporting_timeframe', 'Current week'),
lambda ref_d: _week_start(ref_d),
lambda ref_d, start_d: start_d + timedelta(days=6),
True,
pgettext_lazy('reporting_timeframe', 'by week'),
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
),
(
'week_to_date',
pgettext_lazy('reporting_timeframe', 'Current week to date'),
lambda ref_d: _week_start(ref_d),
lambda ref_d, start_d: ref_d,
False,
pgettext_lazy('reporting_timeframe', 'by week'),
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
),
(
'week_previous',
pgettext_lazy('reporting_timeframe', 'Previous week'),
lambda ref_d: _week_start(ref_d) - timedelta(days=7),
lambda ref_d, start_d: start_d + timedelta(days=6),
False,
pgettext_lazy('reporting_timeframe', 'by week'),
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
),
(
'week_next',
pgettext_lazy('reporting_timeframe', 'Next week'),
lambda ref_d: _week_start(ref_d + timedelta(days=7)),
lambda ref_d, start_d: start_d + timedelta(days=6),
True,
pgettext_lazy('reporting_timeframe', 'by week'),
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
),
(
'month_this',
pgettext_lazy('reporting_timeframe', 'Current month'),
lambda ref_d: ref_d.replace(day=1),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
True,
pgettext_lazy('reporting_timeframe', 'by month'),
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
),
(
'month_to_date',
pgettext_lazy('reporting_timeframe', 'Current month to date'),
lambda ref_d: ref_d.replace(day=1),
lambda ref_d, start_d: ref_d,
False,
pgettext_lazy('reporting_timeframe', 'by month'),
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
),
(
'month_previous',
pgettext_lazy('reporting_timeframe', 'Previous month'),
lambda ref_d: (ref_d.replace(day=1) - timedelta(days=1)).replace(day=1),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
False,
pgettext_lazy('reporting_timeframe', 'by month'),
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
),
(
'month_next',
pgettext_lazy('reporting_timeframe', 'Next month'),
lambda ref_d: ref_d.replace(day=calendar.monthrange(ref_d.year, ref_d.month)[1]) + timedelta(days=1),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
True,
pgettext_lazy('reporting_timeframe', 'by month'),
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
),
(
'quarter_this',
pgettext_lazy('reporting_timeframe', 'Current quarter'),
lambda ref_d: _quarter_start(ref_d),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
True,
pgettext_lazy('reporting_timeframe', 'by quarter'),
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
),
(
'quarter_to_date',
pgettext_lazy('reporting_timeframe', 'Current quarter to date'),
lambda ref_d: _quarter_start(ref_d),
lambda ref_d, start_d: ref_d,
False,
pgettext_lazy('reporting_timeframe', 'by quarter'),
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
),
(
'quarter_previous',
pgettext_lazy('reporting_timeframe', 'Previous quarter'),
lambda ref_d: _quarter_start(_quarter_start(ref_d) - timedelta(days=1)),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
False,
pgettext_lazy('reporting_timeframe', 'by quarter'),
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
),
(
'quarter_next',
pgettext_lazy('reporting_timeframe', 'Next quarter'),
lambda ref_d: ref_d.replace(
day=calendar.monthrange(ref_d.year, _quarter_start(ref_d).month + 2)[1], month=_quarter_start(ref_d).month + 2
) + timedelta(days=1),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
True,
pgettext_lazy('reporting_timeframe', 'by quarter'),
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
),
(
'year_this',
pgettext_lazy('reporting_timeframe', 'Current year'),
lambda ref_d: ref_d.replace(day=1, month=1),
lambda ref_d, start_d: start_d.replace(day=31, month=12),
True,
pgettext_lazy('reporting_timeframe', 'by year'),
lambda start_d, end_d: str(start_d.year),
),
(
'year_to_date',
pgettext_lazy('reporting_timeframe', 'Current year to date'),
lambda ref_d: ref_d.replace(day=1, month=1),
lambda ref_d, start_d: ref_d,
False,
pgettext_lazy('reporting_timeframe', 'by year'),
lambda start_d, end_d: str(start_d.year),
),
(
'year_previous',
pgettext_lazy('reporting_timeframe', 'Previous year'),
lambda ref_d: (ref_d.replace(day=1, month=1) - timedelta(days=1)).replace(day=1, month=1),
lambda ref_d, start_d: start_d.replace(day=31, month=12),
False,
pgettext_lazy('reporting_timeframe', 'by year'),
lambda start_d, end_d: str(start_d.year),
),
(
'year_next',
pgettext_lazy('reporting_timeframe', 'Next year'),
lambda ref_d: ref_d.replace(day=1, month=1, year=ref_d.year + 1),
lambda ref_d, start_d: start_d.replace(day=31, month=12),
True,
pgettext_lazy('reporting_timeframe', 'by year'),
lambda start_d, end_d: str(start_d.year),
),
(
'future',
pgettext_lazy('reporting_timeframe', 'All future (excluding today)'),
lambda ref_d: ref_d + timedelta(days=1),
lambda ref_d, start_d: None,
True,
pgettext_lazy('reporting_timeframe', 'Other'),
lambda start_d, end_d: date_format(start_d, "SHORT_DATE_FORMAT") + ' ',
),
(
'past',
pgettext_lazy('reporting_timeframe', 'All past (including today)'),
lambda ref_d: None,
lambda ref_d, start_d: ref_d,
True, # technically false, but only makes sense to have in a selection that also allows the future, otherwise redundant
pgettext_lazy('reporting_timeframe', 'Other'),
lambda start_d, end_d: ' ' + date_format(end_d, "SHORT_DATE_FORMAT"),
),
)
class DateFrameWidget(forms.MultiWidget):
template_name = 'pretixbase/forms/widgets/dateframe.html'
def __init__(self, *args, **kwargs):
self.timeframe_choices = kwargs.pop('timeframe_choices')
widgets = (
forms.Select(choices=self.timeframe_choices),
forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'Start')}),
forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'End')}),
)
super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value):
if not value:
return ['unset', None, None]
if '/' in value:
return [
'custom',
date.fromisoformat(value.split('/', 1)[0]),
date.fromisoformat(value.split('/', 1)[-1]),
]
return [value, None, None]
def get_context(self, name, value, attrs):
ctx = super().get_context(name, value, attrs)
ctx['required'] = self.timeframe_choices[0][0] == 'unset'
return ctx
def _describe_timeframe(label, start, end, future, describe):
d_start = start(now())
d_end = end(now(), d_start)
details = describe(d_start, d_end)
return f'{label} ({details})'
class DateFrameField(forms.MultiValueField):
default_error_messages = {
**forms.MultiValueField.default_error_messages,
'inconsistent': gettext_lazy('The end date must be after the start date.'),
}
def __init__(self, *args, **kwargs):
include_future_frames = kwargs.pop('include_future_frames')
top_choices = [('custom', gettext_lazy('Custom timeframe'))]
if not kwargs.get('required', True):
top_choices.insert(0, ('unset', pgettext_lazy('reporting_timeframe', 'All time')))
_choices = []
for grouper, group in groupby(REPORTING_DATE_TIMEFRAMES, key=lambda i: i[5]):
options = [
(identifier, _describe_timeframe(label, start, end, future, describe))
for identifier, label, start, end, future, group, describe in group
if include_future_frames or not future
]
if options:
_choices.append((grouper, options))
timeframe_choices = [
('', top_choices)
] + _choices
fields = (
forms.ChoiceField(
choices=timeframe_choices,
required=True
),
forms.DateField(
required=False
),
forms.DateField(
required=False
),
)
if 'widget' not in kwargs:
kwargs['widget'] = DateFrameWidget(timeframe_choices=timeframe_choices)
kwargs.pop('max_length', 0)
kwargs.pop('empty_value', 0)
super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs
)
def compress(self, data_list):
if not data_list:
return None
if data_list[0] == 'unset':
return None
elif data_list[0] == 'custom':
return f'{data_list[1].isoformat() if data_list[1] else ""}/{data_list[2].isoformat() if data_list[2] else ""}'
else:
return data_list[0]
def has_changed(self, initial, data):
if initial is None:
initial = self.widget.decompress(initial)
return super().has_changed(initial, data)
def clean(self, value):
if not value:
return None
if value[0] == 'custom':
if not value[1] and not value[2]:
raise ValidationError(self.error_messages['incomplete'])
if value[1] and value[2] and self.fields[2].to_python(value[2]) < self.fields[1].to_python(value[1]):
raise ValidationError(self.error_messages['inconsistent'])
return super().clean(value)
class SerializerDateFrameField(serializers.CharField):
def to_internal_value(self, data):
if data is None:
return None
try:
resolve_timeframe_to_dates_inclusive(now(), data, pytz.UTC)
except:
raise ValidationError("Invalid date frame")
def to_representation(self, value):
if value is None:
return None
return value
def resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
"""
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of dates
where the first element ist the first possible date value within the timeframe and the second
element is the last possible date value in the timeframe.
Both returned values may be ``None`` for an unlimited interval.
"""
if isinstance(ref_dt, datetime):
ref_dt = ref_dt.astimezone(timezone).date()
if "/" in frame:
start, end = frame.split("/", 1)
return date.fromisoformat(start) if start else None, date.fromisoformat(end) if end else None
for idf, label, start, end, includes_future, *args in REPORTING_DATE_TIMEFRAMES:
if frame == idf:
d_start = start(ref_dt)
d_end = end(ref_dt, d_start)
return d_start, d_end
raise ValueError(f"Invalid timeframe '{frame}'")
def resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
"""
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of datetimes
where the first element ist the first possible datetime within the timeframe and the second
element is the first possible datetime value *not* in the timeframe.
Both returned values may be ``None`` for an unlimited interval.
"""
d_start, d_end = resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone)
dt_start = make_aware(datetime.combine(d_start, time(0, 0, 0)), timezone) if d_start else None
dt_end = make_aware(datetime.combine(d_end + timedelta(days=1), time(0, 0, 0)), timezone) if d_end else None
return dt_start, dt_end
+21 -5
View File
@@ -19,6 +19,12 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from dateutil.rrule import rrulestr
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
@@ -32,11 +38,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
class BanlistValidator:
@@ -101,3 +102,18 @@ class EmailBanlistValidator(BanlistValidator):
banlist = [
settings.PRETIX_EMAIL_NONE_VALUE,
]
def multimail_validate(val):
s = val.split(',')
for part in s:
validate_email(part.strip())
return s
class RRuleValidator:
def __call__(self, value):
try:
rrulestr(value)
except Exception:
raise ValidationError("Not a valid rrule.")
+1 -1
View File
@@ -78,7 +78,7 @@ class BaseQuestionsViewMixin:
form.pos = cartpos or orderpos
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
(form.pos.addon_to.item.admission and form.pos.item.admission and (
(form.pos.addon_to.item.ask_attendee_data and form.pos.item.ask_attendee_data and (
self.request.event.settings.attendee_names_asked or
self.request.event.settings.attendee_emails_asked or
self.request.event.settings.attendee_company_asked or
+5 -3
View File
@@ -28,7 +28,7 @@ from celery import states
from celery.result import AsyncResult
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import HttpResponse, JsonResponse, QueryDict
from django.shortcuts import redirect, render
from django.test import RequestFactory
@@ -149,6 +149,8 @@ class AsyncMixin:
return redirect(self.get_success_url(value))
def error(self, exception):
if isinstance(exception, PermissionDenied):
raise exception
messages.error(self.request, self.get_error_message(exception))
if "ajax" in self.request.POST or "ajax" in self.request.GET:
return JsonResponse({
@@ -337,8 +339,8 @@ class AsyncPostView(AsyncMixin, View):
depend on the request object unless specifically supported by this class. File upload is currently also
not supported.
"""
known_errortypes = ['ValidationError']
expected_exceptions = (ValidationError,)
known_errortypes = ['ValidationError', 'PermissionDenied']
expected_exceptions = (ValidationError, PermissionDenied)
task_base = ProfiledEventTask
def async_set_progress(self, percentage):
+8 -1
View File
@@ -51,6 +51,9 @@ from django_scopes.forms import SafeModelMultipleChoiceField
from pretix.helpers.hierarkey import clean_filename
from ...base.forms import I18nModelForm
from ...helpers.images import (
IMAGE_EXTS, validate_uploaded_file_for_valid_image,
)
# Import for backwards compatibility with okd import paths
from ...base.forms.widgets import ( # noqa
@@ -214,12 +217,16 @@ class ExtValidationMixin:
def clean(self, *args, **kwargs):
data = super().clean(*args, **kwargs)
if isinstance(data, File):
if isinstance(data, UploadedFile):
filename = data.name
ext = os.path.splitext(filename)[1]
ext = ext.lower()
if ext not in self.ext_whitelist:
raise forms.ValidationError(_("Filetype not allowed!"))
if ext in IMAGE_EXTS:
validate_uploaded_file_for_valid_image(data)
return data
+7 -10
View File
@@ -34,12 +34,13 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from decimal import Decimal
from urllib.parse import urlencode, urlparse
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, validate_email
from django.core.validators import MaxValueValidator
from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import (
CheckboxSelectMultiple, formset_factory, inlineformset_factory,
@@ -65,6 +66,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
)
from pretix.base.validators import multimail_validate
from pretix.control.forms import (
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
SplitDateTimePickerWidget,
@@ -135,6 +137,8 @@ class EventWizardBasicsForm(I18nModelForm):
help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate "
"here in percent. If you have a more complicated tax situation, you can add more tax rates and "
"detailed configuration later."),
max_value=Decimal("100.00"),
min_value=Decimal("0.00"),
required=False
)
@@ -861,13 +865,6 @@ class InvoiceSettingsForm(SettingsForm):
return data
def multimail_validate(val):
s = val.split(',')
for part in s:
validate_email(part.strip())
return s
def contains_web_channel_validate(val):
if "web" not in val:
raise ValidationError(_("The online shop must be selected to receive these emails."))
@@ -1206,8 +1203,8 @@ class MailSettingsForm(SettingsForm):
'mail_text_resend_link': ['event', 'order'],
'mail_subject_resend_link': ['event', 'order'],
'mail_subject_resend_link_attendee': ['event', 'order'],
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
'mail_subject_waiting_list': ['event', 'waiting_list_entry'],
'mail_text_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
'mail_subject_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
'mail_text_resend_all_links': ['event', 'orders'],
'mail_subject_resend_all_links': ['event', 'orders'],
'mail_attach_ical_description': ['event', 'event_or_subevent'],
+103
View File
@@ -0,0 +1,103 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from pytz import common_timezones
from pretix.base.models import ScheduledEventExport
from pretix.base.models.exports import ScheduledOrganizerExport
class ScheduledEventExportForm(forms.ModelForm):
class Meta:
model = ScheduledEventExport
fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc',
'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale']
widgets = {
'mail_additional_recipients': forms.TextInput,
'mail_additional_recipients_cc': forms.TextInput,
'mail_additional_recipients_bcc': forms.TextInput,
'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
locale_names = dict(settings.LANGUAGES)
self.fields['locale'] = forms.ChoiceField(
label=_('Language'),
choices=[(a, locale_names[a]) for a in self.instance.event.settings.locales]
)
def clean_mail_additional_recipients(self):
d = self.cleaned_data['mail_additional_recipients'].replace(' ', '')
if len(d.split(',')) > 25:
raise ValidationError(_('Please enter less than 25 recipients.'))
return d
def clean_mail_additional_recipients_cc(self):
d = self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '')
if len(d.split(',')) > 25:
raise ValidationError(_('Please enter less than 25 recipients.'))
return d
def clean_mail_additional_recipients_bcc(self):
d = self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '')
if len(d.split(',')) > 25:
raise ValidationError(_('Please enter less than 25 recipients.'))
return d
class ScheduledOrganizerExportForm(forms.ModelForm):
class Meta:
model = ScheduledOrganizerExport
fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc',
'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale', 'timezone']
widgets = {
'mail_additional_recipients': forms.TextInput,
'mail_additional_recipients_cc': forms.TextInput,
'mail_additional_recipients_bcc': forms.TextInput,
'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
locale_names = dict(settings.LANGUAGES)
self.fields['locale'] = forms.ChoiceField(
label=_('Language'),
choices=[(a, locale_names[a]) for a in self.instance.organizer.settings.locales]
)
self.fields['timezone'] = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Timezone"),
)
def clean_mail_additional_recipients(self):
return self.cleaned_data['mail_additional_recipients'].replace(' ', '')
def clean_mail_additional_recipients_cc(self):
return self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '')
def clean_mail_additional_recipients_bcc(self):
return self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '')
+1 -1
View File
@@ -2264,7 +2264,7 @@ class DeviceFilterForm(FilterForm):
state = forms.ChoiceField(
label=_('Device status'),
choices=[
('', _('All devices')),
('all', _('All devices')),
('active', _('Active devices')),
('revoked', _('Revoked devices'))
],
+37 -1
View File
@@ -295,6 +295,7 @@ class ItemCreateForm(I18nModelForm):
self.user = kwargs.pop('user')
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('admission', True)
kwargs['initial'].setdefault('personalized', True)
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
@@ -403,6 +404,8 @@ class ItemCreateForm(I18nModelForm):
self.instance.sales_channels = list(get_all_sales_channels().keys())
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
if not self.instance.admission:
self.instance.personalized = False
instance = super().save(*args, **kwargs)
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
@@ -494,6 +497,7 @@ class ItemCreateForm(I18nModelForm):
'internal_name',
'category',
'admission',
'personalized',
'default_price',
'tax_rule',
]
@@ -588,13 +592,33 @@ class ItemUpdateForm(I18nModelForm):
'tax_rule',
_("Gift card products should use a tax rule with a rate of 0 percent since sales tax will be applied when the gift card is redeemed.")
)
if d['admission']:
if d.get('admission'):
self.add_error(
'admission',
_(
"Gift card products should not be admission products at the same time."
)
)
if d.get('require_membership') and not d.get('require_membership_types'):
self.add_error(
'require_membership_types',
_(
"If a valid membership is required, at least one valid membership type needs to be selected."
)
)
if not d.get('admission'):
d['personalized'] = False
if d.get('grant_membership_type'):
if not d['grant_membership_type'].transferable and not d['personalized']:
self.add_error(
'personalized' if d['admission'] else 'admission',
_("Your product grants a non-transferable membership and should therefore be a personalized "
"admission ticket. Otherwise customers might not be able to use the membership later. If you "
"want the membership to be non-personalized, set the membership type to be transferable.")
)
return d
def clean_picture(self):
@@ -615,6 +639,7 @@ class ItemUpdateForm(I18nModelForm):
'active',
'sales_channels',
'admission',
'personalized',
'description',
'picture',
'default_price',
@@ -792,6 +817,17 @@ class ItemVariationForm(I18nModelForm):
}),
}
def clean(self):
d = super().clean()
if d.get('require_membership') and not d.get('require_membership_types'):
self.add_error(
'require_membership_types',
_(
"If a valid membership is required, at least one valid membership type needs to be selected."
)
)
return d
def save(self, commit=True):
instance = super().save(commit)
self.meta_fields = []
+5 -1
View File
@@ -227,6 +227,10 @@ class ExporterForm(forms.Form):
elif isinstance(v, models.QuerySet):
data[k] = [m.pk for m in v]
if 'all_events' in self.fields and 'events' in self.fields:
if not data.get('all_events') and not data.get('events'):
raise ValidationError(_('Please select some events.'))
return data
@@ -266,7 +270,7 @@ class OtherOperationsForm(forms.Form):
notify = forms.BooleanField(
label=_('Notify user'),
required=False,
initial=True,
initial=False,
help_text=_(
'Send an email to the customer notifying that their order has been changed.'
)
+251
View File
@@ -0,0 +1,251 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from datetime import timedelta
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
from django import forms
from django.utils.dates import MONTHS, WEEKDAYS
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
class RRuleForm(forms.Form):
# TODO: calendar.setfirstweekday
freq = forms.ChoiceField(
choices=[
('yearly', _('year(s)')),
('monthly', _('month(s)')),
('weekly', _('week(s)')),
('daily', _('day(s)')),
],
initial='weekly'
)
interval = forms.IntegerField(
label=_('Interval'),
initial=1,
min_value=1,
widget=forms.NumberInput(attrs={'min': '1'})
)
dtstart = forms.DateField(
label=_('Start date'),
widget=forms.DateInput(
attrs={
'class': 'datepickerfield',
'required': 'required'
}
),
initial=lambda: now().astimezone(get_current_timezone()).date()
)
end = forms.ChoiceField(
choices=[
('count', ''),
('until', ''),
('forever', ''),
],
initial='count',
widget=forms.RadioSelect
)
count = forms.IntegerField(
label=_('Number of repetitions'),
initial=10
)
until = forms.DateField(
widget=forms.DateInput(
attrs={
'class': 'datepickerfield',
'required': 'required'
}
),
label=_('Last date'),
required=True,
initial=lambda: now() + timedelta(days=30)
)
yearly_bysetpos = forms.ChoiceField(
choices=[
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
)
yearly_same = forms.ChoiceField(
choices=[
('on', ''),
('off', ''),
],
initial='on',
widget=forms.RadioSelect
)
yearly_byweekday = forms.ChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
],
required=False
)
yearly_bymonth = forms.ChoiceField(
choices=[
(str(i), MONTHS[i]) for i in range(1, 13)
],
required=False
)
monthly_same = forms.ChoiceField(
choices=[
('on', ''),
('off', ''),
],
initial='on',
widget=forms.RadioSelect
)
monthly_bysetpos = forms.ChoiceField(
choices=[
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
)
monthly_byweekday = forms.ChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
],
required=False
)
weekly_byweekday = forms.MultipleChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
],
required=False,
widget=forms.CheckboxSelectMultiple
)
def parse_weekdays(self, value):
m = {
'MO': 0,
'TU': 1,
'WE': 2,
'TH': 3,
'FR': 4,
'SA': 5,
'SU': 6
}
if ',' in value:
return [m.get(a) for a in value.split(',')]
else:
return m.get(value)
def to_rrule(self):
rule_kwargs = {}
rule_kwargs['dtstart'] = self.cleaned_data['dtstart']
rule_kwargs['interval'] = self.cleaned_data['interval']
if self.cleaned_data['freq'] == 'yearly':
freq = YEARLY
if self.cleaned_data['yearly_same'] == "off":
rule_kwargs['bysetpos'] = int(self.cleaned_data['yearly_bysetpos'])
rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['yearly_byweekday'])
rule_kwargs['bymonth'] = int(self.cleaned_data['yearly_bymonth'])
elif self.cleaned_data['freq'] == 'monthly':
freq = MONTHLY
if self.cleaned_data['monthly_same'] == "off":
rule_kwargs['bysetpos'] = int(self.cleaned_data['monthly_bysetpos'])
rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['monthly_byweekday'])
elif self.cleaned_data['freq'] == 'weekly':
freq = WEEKLY
if self.cleaned_data['weekly_byweekday']:
rule_kwargs['byweekday'] = [self.parse_weekdays(a) for a in self.cleaned_data['weekly_byweekday']]
elif self.cleaned_data['freq'] == 'daily':
freq = DAILY
if self.cleaned_data['end'] == 'count':
rule_kwargs['count'] = self.cleaned_data['count']
elif self.cleaned_data['end'] == 'until':
rule_kwargs['until'] = self.cleaned_data['until']
return rrule(freq, **rule_kwargs)
@staticmethod
def initial_from_rrule(rule: rrule):
initial = {}
if isinstance(rule, str):
rule = rrulestr(rule)
_rule = rule._original_rule
initial['dtstart'] = rule._dtstart
initial['interval'] = rule._interval
if rule._freq == YEARLY:
initial['freq'] = 'yearly'
initial['yearly_bysetpos'] = _rule.get('bysetpos')
initial['yearly_byweekday'] = _rule.get('byweekday')
initial['yearly_bymonth'] = _rule.get('bymonth')
elif rule._freq == MONTHLY:
initial['freq'] = 'monthly'
initial['monthly_bysetpos'] = _rule.get('bysetpos')
initial['monthly_byweekday'] = _rule.get('byweekday')
elif rule._freq == WEEKLY:
initial['freq'] = 'weekly'
initial['weekly_byweekday'] = _rule.get('byweekday')
elif rule._freq == DAILY:
initial['freq'] = 'daily'
if rule._count:
initial['end'] = 'count'
initial['count'] = rule._count
elif rule._until:
initial['end'] = 'until'
initial['until'] = rule._until
else:
initial['end'] = 'forever'
return initial
+5 -157
View File
@@ -19,17 +19,15 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from datetime import datetime, timedelta
from datetime import datetime
from urllib.parse import urlencode
from django import forms
from django.forms import formset_factory
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.dates import MONTHS, WEEKDAYS
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.utils.translation import gettext_lazy as _
from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm
@@ -39,6 +37,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.rrule import RRuleForm
from pretix.helpers.money import change_decimal_field
@@ -440,166 +439,15 @@ class CheckinListFormSet(I18nInlineFormSet):
return form
class RRuleForm(forms.Form):
# TODO: calendar.setfirstweekday
class RRuleFormSetForm(RRuleForm):
exclude = forms.BooleanField(
label=_('Exclude these dates instead of adding them.'),
required=False
)
freq = forms.ChoiceField(
choices=[
('yearly', _('year(s)')),
('monthly', _('month(s)')),
('weekly', _('week(s)')),
('daily', _('day(s)')),
],
initial='weekly'
)
interval = forms.IntegerField(
label=_('Interval'),
initial=1,
min_value=1,
widget=forms.NumberInput(attrs={'min': '1'})
)
dtstart = forms.DateField(
label=_('Start date'),
widget=forms.DateInput(
attrs={
'class': 'datepickerfield',
'required': 'required'
}
),
initial=lambda: now().date()
)
end = forms.ChoiceField(
choices=[
('count', ''),
('until', ''),
],
initial='count',
widget=forms.RadioSelect
)
count = forms.IntegerField(
label=_('Number of repetitions'),
initial=10
)
until = forms.DateField(
widget=forms.DateInput(
attrs={
'class': 'datepickerfield',
'required': 'required'
}
),
label=_('Last date'),
required=True,
initial=lambda: now() + timedelta(days=30)
)
yearly_bysetpos = forms.ChoiceField(
choices=[
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
)
yearly_same = forms.ChoiceField(
choices=[
('on', ''),
('off', ''),
],
initial='on',
widget=forms.RadioSelect
)
yearly_byweekday = forms.ChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
],
required=False
)
yearly_bymonth = forms.ChoiceField(
choices=[
(str(i), MONTHS[i]) for i in range(1, 13)
],
required=False
)
monthly_same = forms.ChoiceField(
choices=[
('on', ''),
('off', ''),
],
initial='on',
widget=forms.RadioSelect
)
monthly_bysetpos = forms.ChoiceField(
choices=[
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
)
monthly_byweekday = forms.ChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
],
required=False
)
weekly_byweekday = forms.MultipleChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
],
required=False,
widget=forms.CheckboxSelectMultiple
)
def parse_weekdays(self, value):
m = {
'MO': 0,
'TU': 1,
'WE': 2,
'TH': 3,
'FR': 4,
'SA': 5,
'SU': 6
}
if ',' in value:
return [m.get(a) for a in value.split(',')]
else:
return m.get(value)
RRuleFormSet = formset_factory(
RRuleForm,
RRuleFormSetForm,
can_order=False, can_delete=True, extra=1
)
+10
View File
@@ -315,6 +315,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.organizer.changed': _('The organizer has been changed.'),
'pretix.organizer.settings': _('The organizer settings have been changed.'),
'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'),
'pretix.organizer.export.schedule.added': _('A scheduled export has been added.'),
'pretix.organizer.export.schedule.changed': _('A scheduled export has been changed.'),
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.webhook.created': _('The webhook has been created.'),
@@ -409,6 +414,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'),
'pretix.event.export.schedule.added': _('A scheduled export has been added.'),
'pretix.event.export.schedule.changed': _('A scheduled export has been changed.'),
'pretix.event.export.schedule.deleted': _('A scheduled export has been deleted.'),
'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.control.auth.user.created': _('The user has been created.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
@@ -40,6 +40,7 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/sb-admin-2.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/rrule.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/variations.js" %}"></script>
@@ -82,8 +82,10 @@
<thead>
<tr>
<th>
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
{% endif %}
</th>
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
@@ -125,7 +127,7 @@
{% for e in entries %}
<tr>
<td>
{% if "can_change_orders" in request.eventpermset %}
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
<input type="checkbox" name="checkin" id="id_checkin" class="" value="{{ e.pk }}"/>
{% endif %}
</td>
@@ -198,7 +200,7 @@
</tbody>
</table>
</div>
{% if "can_change_orders" in request.eventpermset %}
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
<button type="submit" class="btn btn-primary btn-save">
<span class="fa fa-sign-in" aria-hidden="true"></span>
{% trans "Check-In selected attendees" %}
@@ -207,6 +209,8 @@
<span class="fa fa-sign-out" aria-hidden="true"></span>
{% trans "Check-Out selected attendees" %}
</button>
{% endif %}
{% if "can_change_orders" in request.eventpermset %}
<button type="submit" class="btn btn-danger btn-save" name="revert" value="true">
<span class="fa fa-trash" aria-hidden="true"></span>
{% trans "Delete all check-ins of selected attendees" %}
@@ -36,12 +36,16 @@
</div>
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
{% if request.event %}
{% for l in request.event.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% for l, n in settings.LANGUAGES %}
{% if l in request.event.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endif %}
{% endfor %}
{% else %}
{% for l in request.organizer.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% for l, n in settings.LANGUAGES %}
{% if l in request.organizer.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endif %}
{% endfor %}
{% endif %}
</div>
@@ -90,7 +90,7 @@
</div>
</div>
<h4>{% trans "Attendee data (once per admission ticket)" %}</h4>
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
{% bootstrap_field sform.attendee_names_asked_required layout="control" %}
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %}
@@ -20,13 +20,19 @@
<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>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %} id="admission_on">
<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.
By default, we will only offer ticket downloads for these products.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
Only purchases of such products will be considered "attendees" for most statistical
purposes or within some plugins.
{% endblocktrans %}
</div>
<div class="help-block">
@@ -40,12 +46,12 @@
<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>
<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.
A product that does not represent a person. By default, we will not offer ticket downloads
(but you can still enable ticket downloads in event settings or product settings).
{% endblocktrans %}
</div>
<div class="help-block">
@@ -58,6 +64,47 @@
</div>
</div>
<div class="form-group" data-display-dependency="#admission_on">
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-id-card-o"></span>
<strong>{% trans "Personalized ticket" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
When this ticket is purchased, the system will ask for a name or other details according
to your event settings.
{% endblocktrans %}
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
<br>
<span class="text-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
</span>
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
{% endif %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-circle-o"></span>
<strong>{% trans "Non-personalized ticket" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
The system will not ask for a name or other attendee details. This only affects
system-provided fields, you can still add your own questions.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.category layout="control" %}
<div class="form-group">
@@ -27,13 +27,19 @@
{% endfor %}
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %} id="admission_on">
<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.
By default, we will only offer ticket downloads for these products.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
Only purchases of such products will be considered "attendees" for most statistical
purposes or within some plugins.
{% endblocktrans %}
</div>
<div class="help-block">
@@ -51,8 +57,8 @@
<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.
A product that does not represent a person. By default, we will not offer ticket downloads
(but you can still enable ticket downloads in event settings or product settings).
{% endblocktrans %}
</div>
<div class="help-block">
@@ -65,6 +71,52 @@
</div>
</div>
<div class="form-group" data-display-dependency="#admission_on">
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
<div class="col-md-9">
{% for e in form.errors.personalized %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-id-card-o"></span>
<strong>{% trans "Personalized ticket" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
When this ticket is purchased, the system will ask for a name or other details according
to your event settings.
{% endblocktrans %}
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
<br>
<span class="text-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
</span>
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
{% endif %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-file-text-o"></span>
<strong>{% trans "Non-personalized ticket" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
The system will not ask for a name or other attendee details. This only affects
system-provided fields, you can still add your own questions.
{% 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" %}
@@ -81,7 +81,11 @@
</td>
<td>
{% if i.admission %}
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket" %}"></span>
{% if i.personalized %}
<span class="fa fa-id-card-o fa-fw text-muted" data-toggle="tooltip" title="{% trans "Personalized admission ticket" %}"></span>
{% else %}
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket without personalization" %}"></span>
{% endif %}
{% elif i.issue_giftcard %}
<span class="fa fa-gift fa-fw text-muted" data-toggle="tooltip" title="{% trans "Gift card" %}"></span>
{% endif %}
@@ -76,7 +76,7 @@
{% endfor %}
</ul>
{% else %}
<small>{% trans "All admission products" %}</small>
<small>{% trans "All personalized products" %}</small>
{% endif %}
</td>
<td class="dnd-container">
@@ -184,9 +184,11 @@
<dt>{% trans "Order locale" %}</dt>
<dd>
{{ display_locale }}
<a href="{% url "control:event.order.locale" 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>
{% if "can_change_orders" in request.eventpermset %}
<a href="{% url "control:event.order.locale" 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>
{% endif %}
</dd>
{% if order.status == "n" %}
<dt>{% trans "Expiry date" %}</dt>
@@ -206,9 +208,11 @@
{{ order.customer.identifier }} {{ order.customer.email }}
</a>
{% 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>
{% if "can_change_orders" in request.eventpermset %}
<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>
{% endif %}
</dd>
{% endif %}
<dt>{% trans "Contact email" %}</dt>
@@ -217,21 +221,23 @@
{% 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 %}
<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>
{% if order.email %}
<a href="{% url "control:event.order.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
{% if "can_change_orders" in request.eventpermset %}
<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>
{% if order.status != "c" %}
<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 %}">
{% csrf_token %}
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% if order.email %}
<a href="{% url "control:event.order.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
{% if order.status != "c" %}
<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 %}">
{% csrf_token %}
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
{% endif %}
{% endif %}
</dd>
@@ -239,9 +245,11 @@
<dt>{% trans "Phone number" %}</dt>
<dd>
{{ order.phone|default_if_none:""|phone_format }}
<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>
{% if "can_change_orders" in request.eventpermset %}
<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>
{% endif %}
</dd>
{% endif %}
{% if invoices %}
@@ -306,7 +314,7 @@
{% trans "Email invoices" %}
</a>
{% endif %}
{% if can_generate_invoice %}
{% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<br/>
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.geninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
@@ -317,7 +325,7 @@
</form>
{% endif %}
</dd>
{% elif can_generate_invoice %}
{% elif can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<dt>{% trans "Invoices" %}</dt>
<dd>
<form class="form-inline helper-display-inline" method="post"
@@ -335,11 +343,11 @@
<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 '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 %}">
<span class="fa fa-edit"></span>
{% trans "Change products" %}
@@ -460,12 +468,12 @@
{% endif %}
{% if line.has_questions %}
<dl>
{% if line.item.admission and event.settings.attendee_names_asked %}
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
<dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_emails_asked %}
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
<dt>{% trans "Attendee email" %}</dt>
<dd>
{% if line.attendee_email %}
@@ -488,7 +496,7 @@
{% endif %}
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_company_asked %}
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
<dt>
{% trans "Attendee company" %}
</dt>
@@ -496,7 +504,7 @@
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_addresses_asked %}
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
<dt>
{% trans "Attendee address" %}
</dt>
@@ -754,7 +762,7 @@
{% endfor %}
</tbody>
</table>
{% if order.payment_refund_sum > 0 %}
{% if order.payment_refund_sum > 0 and "can_change_orders" in request.eventpermset %}
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Create a refund" %}
</a>
@@ -942,9 +950,11 @@
{% bootstrap_field comment_form.comment show_help=True show_label=False %}
{% bootstrap_field comment_form.custom_followup_at %}
{% bootstrap_field comment_form.checkin_attention show_help=True show_label=False %}
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
{% if "can_change_orders" in request.eventpermset %}
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
{% endif %}
</form>
</div>
</div>
@@ -4,35 +4,113 @@
{% load order_overview %}
{% block title %}{% trans "Data export" %}{% endblock %}
{% block content %}
<h1>
<h1>
{% trans "Data export" %}
{% if "identifier" in request.GET %}
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
{% endif %}
</h1>
{% for e in exporters %}
<details class="panel panel-default"
{% if request.GET.identifier == e.identifier or request.POST.exporter == e.identifier %}open{% endif %}>
<summary class="panel-heading">
<h3 class="panel-title">
{{ e.verbose_name }}
<i class="fa fa-angle-down collapse-indicator"></i>
</h3>
</summary>
<div id="{{ e.identifier }}">
<div class="panel-body">
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
{% bootstrap_form e.form layout='control' %}
<button class="btn btn-primary pull-right flip" type="submit">
<span class="icon icon-upload"></span> {% trans "Start export" %}
</button>
</form>
</div>
</div>
</details>
{% if scheduled %}
<h2>{% trans "Scheduled exports" %}</h2>
<ul class="list-group">
{% for s in scheduled %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-5 col-md-4 col-xs-12">
<span class="fa fa-fw fa-folder"></span>
{{ s.export_verbose_name }}
<br>
<span class="text-muted">
<span class="fa fa-fw fa-user"></span>
{{ s.owner.fullname|default:s.owner.email }}
</span>
</div>
<div class="col-lg-5 col-md-6 col-xs-12">
{% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %}
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "No next run scheduled" %}
{% endif %}
{% if s.export_verbose_name == "?" %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Exporter not found" %}
</strong>
{% elif s.error_counter >= 5 %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Disabled due to multiple failures" %}
</strong>
{% elif s.error_counter > 0 %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Failed recently" %}
</strong>
{% endif %}
<span class="text-muted">
<br>
<span class="fa fa-fw fa-envelope-o"></span>
{{ s.mail_subject }}
</span>
</div>
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
{% if s.export_verbose_name != "?" %}
<button type="submit" class="btn btn-default" title="{% trans "Run export now" %}" data-toggle="tooltip">
<span class="fa fa-download"></span>
</button>
<button formaction="{% url "control:event.orders.export.scheduled.run" organizer=request.organizer.slug event=request.event.slug pk=s.pk %}"
type="submit"
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
<span class="fa fa-play" aria-hidden="true"></span>
</button>
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span>
</a>
{% endif %}
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span>
</a>
</form>
</div>
</div>
</li>
{% endfor %}
</ul>
{% if is_paginated %}
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endif %}
{% regroup exporters by category as category_list %}
{% for c, c_ex in category_list %}
{% if c %}
<h2>{{ c }}</h2>
{% else %}
<h2>{% trans "Other exports" %}</h2>
{% endif %}
<div class="list-group large-link-group">
{% for e in c_ex %}
<a class="list-group-item" href="?identifier={{ e.identifier }}">
<h4>
{{ e.verbose_name }}
{% if e.featured %}
<span class="fa fa-star text-success" data-toggle="tooltip"
title="{% trans "Recommended for new users" %}" aria-hidden="true"></span>
{% endif %}
</h4>
{% if e.description %}
<p>
{{ e.description }}
</p>
{% endif %}
</a>
{% endfor %}
</div>
{% endfor %}
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
{% block content %}
<h1>{% trans "Delete scheduled export" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:event.orders.export" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Data export" %}{% endblock %}
{% block content %}
<h1>
{% trans "Data export" %}
{% if exporter %}
<small>
{{ exporter.verbose_name }}
</small>
{% endif %}
</h1>
{% if exporter.description %}
<p class="help-block">{{ exporter.description }}</p>
{% endif %}
{% if schedule_form %}
{% bootstrap_form_errors schedule_form layout='control' %}
{% endif %}
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
<fieldset>
<legend>{% trans "Export options" %}</legend>
{% bootstrap_form exporter.form layout='control' %}
</fieldset>
{% if schedule_form %}
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% trans "Save" %}
</button>
</div>
{% else %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
<span class="fa fa-download" aria-hidden="true"></span>
{% trans "Start export" %}
</button>
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
class="btn btn-default btn-alternative" data-no-asynctask>
<span class="fa fa-clock-o" aria-hidden="true"></span>
{% trans "Schedule export" %}
</button>
</div>
{% endif %}
</form>
{% endblock %}
@@ -0,0 +1,141 @@
{% load i18n %}
{% load bootstrap3 %}
{% load captureas %}
<fieldset>
<legend>{% trans "Schedule" %}</legend>
{% bootstrap_field schedule_form.schedule_rrule_time layout='control' %}
{% if schedule_form.timezone %}
{% bootstrap_field schedule_form.timezone layout='control' %}
{% endif %}
{% bootstrap_form_errors rrule_form layout='control' %}
{% bootstrap_field rrule_form.dtstart layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Repetition schedule" %}</label>
<div class="col-md-9">
<div class="form-inline rrule-form">
{% captureas ffield_freq %}
{% bootstrap_field rrule_form.freq layout="inline" %}
{% endcaptureas %}
{% captureas ffield_interval %}
{% bootstrap_field rrule_form.interval layout="inline" %}
{% endcaptureas %}
{% captureas ffield_yearly_bysetpos %}
{% bootstrap_field rrule_form.yearly_bysetpos layout="inline" %}
{% endcaptureas %}
{% captureas ffield_yearly_byweekday %}
{% bootstrap_field rrule_form.yearly_byweekday layout="inline" %}
{% endcaptureas %}
{% captureas ffield_yearly_bymonth %}
{% bootstrap_field rrule_form.yearly_bymonth layout="inline" %}
{% endcaptureas %}
{% captureas ffield_monthly_bysetpos %}
{% bootstrap_field rrule_form.monthly_bysetpos layout="inline" %}
{% endcaptureas %}
{% captureas ffield_monthly_byweekday %}
{% bootstrap_field rrule_form.monthly_byweekday layout="inline" %}
{% endcaptureas %}
{% captureas ffield_count %}
{% bootstrap_field rrule_form.count layout="inline" %}
{% endcaptureas %}
{% captureas ffield_until %}
{% bootstrap_field rrule_form.until layout="inline" %}
{% endcaptureas %}
{% blocktrans trimmed with freq=ffield_freq interval=ffield_interval start=ffield_dtstart %}
Repeat every {{ interval }} {{ freq }}
{% endblocktrans %}<br>
<div class="repeat-yearly">
<div class="radio">
<label>
{{ rrule_form.yearly_same.0 }}
{% trans "At the same date every year" %}
</label><br>
<label>
{{ rrule_form.yearly_same.1 }}
{% blocktrans trimmed with setpos=ffield_yearly_bysetpos weekday=ffield_yearly_byweekday month=ffield_yearly_bymonth %}
On the {{ setpos }} {{ weekday }} of {{ month }}
{% endblocktrans %}<br>
</label>
</div>
</div>
<div class="repeat-monthly">
<div class="radio">
<label>
{{ rrule_form.monthly_same.0 }}
{% trans "At the same date every month" %}
</label><br>
<label>
{{ rrule_form.monthly_same.1 }}
{% blocktrans trimmed with setpos=ffield_monthly_bysetpos weekday=ffield_monthly_byweekday %}
On the {{ setpos }} {{ weekday }}
{% endblocktrans %}<br>
</label>
</div>
</div>
<div class="repeat-weekly">
{% bootstrap_field rrule_form.weekly_byweekday layout="inline" %}
</div>
<div class="repeat-until">
<div class="radio">
<label>
{{ rrule_form.end.0 }}
{% blocktrans trimmed with count=ffield_count %}
Repeat for {{ count }} times
{% endblocktrans %}
</label><br>
<label>
{{ rrule_form.end.1 }}
{% blocktrans trimmed with until=ffield_until %}
Repeat until {{ until }}
{% endblocktrans %}<br>
</label><br>
<label>
{{ rrule_form.end.2 }}
{% blocktrans trimmed %}
Forever
{% endblocktrans %}<br>
</label>
</div>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Email" %}</legend>
<div class="alert alert-info">
{% trans "Every time your schedule is executed, the report will be sent via email." %}
{% trans "Please note the following limitations:" %}
<ul>
<li>
{% trans "Email is not a strongly encrypted medium. We only recommend using this for exports that output e.g. statistical data, not for reports that include sensitive personal data." %}
</li>
<li>
{% trans "Email is not made for large files. If your export ends up to be larger than 20 megabytes, it will not be sent." %}
</li>
</ul>
</div>
<div class="form-group">
<label class="col-md-3 control-label" for="id_schedule-owner">{% trans "Owner" %}</label>
<div class="col-md-9">
<input type="text" name="schedule-owner" value="{{ schedule_form.instance.owner.email }}" disabled
class="form-control" title=""
id="id_schedule-owner">
<div class="help-block">
{% trans "The export will be performed using the owner's permission level, i.e. if the owner loses access to the data, the report will stop." %}
{% trans "The owner will receive the result as well as any error messages." %}
{% trans "The additional recipients you add below will only receive an email if the report was successful." %}
{% trans "All recipients of the export will be able to see who the owner of the report is." %}
</div>
</div>
</div>
{% bootstrap_field schedule_form.mail_additional_recipients layout='control' %}
{% bootstrap_field schedule_form.mail_additional_recipients_cc layout='control' %}
{% bootstrap_field schedule_form.mail_additional_recipients_bcc layout='control' %}
{% bootstrap_field schedule_form.locale layout='control' %}
{% bootstrap_field schedule_form.mail_subject layout='control' %}
{% bootstrap_field schedule_form.mail_template layout='control' %}
</fieldset>
@@ -1,37 +1,110 @@
{% extends "pretixcontrol/event/base.html" %}
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load order_overview %}
{% block title %}{% trans "Data export" %}{% endblock %}
{% block content %}
<h1>
<h1>
{% trans "Data export" %}
{% if "identifier" in request.GET %}
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
{% endif %}
</h1>
{% for e in exporters %}
<details class="panel panel-default" {% if "identifier" in request.GET or "exporter" in request.POST %}open{% endif %}>
<summary class="panel-heading">
<h3 class="panel-title">
{{ e.verbose_name }}
<i class="fa fa-angle-down collapse-indicator"></i>
</h3>
</summary>
<div id="{{ e.identifier }}">
<div class="panel-body">
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
{% bootstrap_form e.form layout='control' %}
<button class="btn btn-primary pull-right flip" type="submit">
<span class="icon icon-upload"></span> {% trans "Start export" %}
</button>
</form>
</div>
</div>
</details>
{% if scheduled %}
<h2>{% trans "Scheduled exports" %}</h2>
<ul class="list-group">
{% for s in scheduled %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-5 col-md-4 col-xs-12">
<span class="fa fa-fw fa-folder"></span>
{{ s.export_verbose_name }}
<br>
<span class="text-muted">
<span class="fa fa-fw fa-user"></span>
{{ s.owner.fullname|default:s.owner.email }}
</span>
</div>
<div class="col-lg-5 col-md-6 col-xs-12">
{% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %}
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "No next run scheduled" %}
{% endif %}
{% if s.export_verbose_name == "?" %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Exporter not found" %}
</strong>
{% elif s.error_counter >= 5 %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Disabled due to multiple failures" %}
</strong>
{% elif s.error_counter > 0 %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Failed recently" %}
</strong>
{% endif %}
<span class="text-muted">
<br>
<span class="fa fa-fw fa-envelope-o"></span>
{{ s.mail_subject }}
</span>
</div>
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
{% if s.export_verbose_name != "?" %}
<button type="submit" class="btn btn-default" title="{% trans "Run export now and download result" %}" data-toggle="tooltip">
<span class="fa fa-download"></span>
</button>
<button formaction="{% url "control:organizer.export.scheduled.run" organizer=request.organizer.slug pk=s.pk %}"
type="submit"
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
<span class="fa fa-play" aria-hidden="true"></span>
</button>
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span>
</a>
{% endif %}
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span>
</a>
</form>
</div>
</div>
</li>
{% endfor %}
</ul>
{% if is_paginated %}
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endif %}
{% regroup exporters by category as category_list %}
{% for c, c_ex in category_list %}
{% if c %}
<h2>{{ c }}</h2>
{% else %}
<h2>{% trans "Other exports" %}</h2>
{% endif %}
<div class="list-group large-link-group">
{% for e in c_ex %}
<a class="list-group-item" href="?identifier={{ e.identifier }}">
<h4>{{ e.verbose_name }}</h4>
{% if e.description %}
<p>
{{ e.description }}
</p>
{% endif %}
</a>
{% endfor %}
</div>
{% endfor %}
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
{% block content %}
<h1>{% trans "Delete scheduled export" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:organizer.export" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,52 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Data export" %}{% endblock %}
{% block content %}
<h1>
{% trans "Data export" %}
{% if exporter %}
<small>
{{ exporter.verbose_name }}
</small>
{% endif %}
</h1>
{% if exporter.description %}
<p class="help-block">{{ exporter.description }}</p>
{% endif %}
{% if schedule_form %}
{% bootstrap_form_errors schedule_form layout='control' %}
{% endif %}
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
<fieldset>
<legend>{% trans "Export options" %}</legend>
{% bootstrap_form exporter.form layout='control' %}
</fieldset>
{% if schedule_form %}
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% trans "Save" %}
</button>
</div>
{% else %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
<span class="fa fa-download" aria-hidden="true"></span>
{% trans "Start export" %}
</button>
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
class="btn btn-default btn-alternative" data-no-asynctask>
<span class="fa fa-clock-o" aria-hidden="true"></span>
{% trans "Schedule export" %}
</button>
</div>
{% endif %}
</form>
{% endblock %}
@@ -458,7 +458,9 @@
</form>
</div>
</div>
<script type="text/plain" id="schema-url">{% static "schema/pdf-layout.schema.json" %}</script>
<script type="text/javascript" src="{% static "pdfjs/pdf.js" %}"></script>
<script type="text/javascript" src="{% static "ajv/ajv2020.bundle.min.js" %}"></script>
<script type="text/javascript" src="{% static "fabric/fabric.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/editor.js" %}"></script>
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_dark.png' %}" id="poweredby-dark" class="sr-only">
+6
View File
@@ -207,6 +207,10 @@ urlpatterns = [
re_path(r'^organizer/(?P<organizer>[^/]+)/logs', organizer.LogView.as_view(), name='organizer.log'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/$', organizer.ExportView.as_view(), name='organizer.export'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/do$', organizer.ExportDoView.as_view(), name='organizer.export.do'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/run$', organizer.RunScheduledExportView.as_view(),
name='organizer.export.scheduled.run'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(),
name='organizer.export.scheduled.delete'),
re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'),
re_path(r'^events/$', main.EventList.as_view(), name='events'),
re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
@@ -386,6 +390,8 @@ urlpatterns = [
re_path(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'),
re_path(r'^orders/import/(?P<file>[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'),
re_path(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
re_path(r'^orders/export/(?P<pk>[^/]+)/run$', orders.RunScheduledExportView.as_view(), name='event.orders.export.scheduled.run'),
re_path(r'^orders/export/(?P<pk>[^/]+)/delete$', orders.DeleteScheduledExportView.as_view(), name='event.orders.export.scheduled.delete'),
re_path(r'^orders/export/do$', orders.ExportDoView.as_view(), name='event.orders.export.do'),
re_path(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
re_path(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
+6
View File
@@ -353,6 +353,12 @@ class Recover(TemplateView):
user.save()
messages.success(request, _('You can now login using your new password.'))
user.log_action('pretix.control.auth.user.forgot_password.recovered')
has_redis = settings.HAS_REDIS
if has_redis:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
rc.delete('pretix_pwreset_%s' % user.id)
return redirect('control:auth.login')
else:
return self.get(request, *args, **kwargs)
+10 -3
View File
@@ -34,6 +34,7 @@
import dateutil.parser
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery
from django.http import Http404, HttpResponseRedirect
@@ -42,7 +43,7 @@ from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.timezone import is_aware, make_aware, now
from django.utils.translation import gettext_lazy as _
from django.views.generic import DeleteView, ListView
from django.views.generic import ListView
from pytz import UTC
from pretix.base.channels import get_all_sales_channels
@@ -56,6 +57,7 @@ from pretix.control.forms.filter import (
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views import CreateView, PaginationMixin, UpdateView
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.models import modelcopy
@@ -171,7 +173,7 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, CheckInList
class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMixin, AsyncPostView):
template_name = 'pretixcontrol/organizers/device_bulk_edit.html'
permission = 'can_change_orders'
permission = ('can_change_orders', 'can_checkin_orders')
context_object_name = 'device'
def dispatch(self, request, *args, **kwargs):
@@ -181,11 +183,16 @@ class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMi
def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by()
def get_error_url(self):
return self.get_success_url(None)
@transaction.atomic()
def async_post(self, request, *args, **kwargs):
self.list = get_object_or_404(request.event.checkin_lists.all(), pk=kwargs.get("list"))
positions = self.get_queryset()
if request.POST.get('revert') == 'true':
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request):
raise PermissionDenied()
for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
Checkin.objects.filter(position=op, list=self.list).delete()
@@ -388,7 +395,7 @@ class CheckinListUpdate(EventPermissionRequiredMixin, UpdateView):
return super().form_invalid(form)
class CheckinListDelete(EventPermissionRequiredMixin, DeleteView):
class CheckinListDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = CheckinList
template_name = 'pretixcontrol/checkin/list_delete.html'
permission = 'can_change_event_settings'
+2 -2
View File
@@ -35,7 +35,6 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import ListView
from django.views.generic.edit import DeleteView
from pretix.base.models import CartPosition, Discount
from pretix.control.forms.discounts import DiscountForm
@@ -45,10 +44,11 @@ from pretix.control.permissions import (
from pretix.helpers.models import modelcopy
from ...base.channels import get_all_sales_channels
from ...helpers.compat import CompatDeleteView
from . import CreateView, PaginationMixin, UpdateView
class DiscountDelete(EventPermissionRequiredMixin, DeleteView):
class DiscountDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = Discount
template_name = 'pretixcontrol/items/discount_delete.html'
permission = 'can_change_items'
+3 -2
View File
@@ -58,7 +58,7 @@ from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.generic import DeleteView, FormView, ListView
from django.views.generic import FormView, ListView
from django.views.generic.base import TemplateView, View
from django.views.generic.detail import SingleObjectMixin
from i18nfield.strings import LazyI18nString
@@ -94,6 +94,7 @@ from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota,
)
from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList
from ...helpers.compat import CompatDeleteView
from ...helpers.format import format_map
from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, PaginationMixin, UpdateView
@@ -1243,7 +1244,7 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView
return super().form_invalid(form)
class TaxDelete(EventSettingsViewMixin, EventPermissionRequiredMixin, DeleteView):
class TaxDelete(EventSettingsViewMixin, EventPermissionRequiredMixin, CompatDeleteView):
model = TaxRule
template_name = 'pretixcontrol/event/tax_delete.html'
permission = 'can_change_event_settings'
+6 -7
View File
@@ -56,7 +56,6 @@ from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import ListView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.edit import DeleteView
from django_countries.fields import Country
from pretix.api.serializers.item import (
@@ -85,6 +84,7 @@ from pretix.control.signals import item_forms, item_formsets
from pretix.helpers.models import modelcopy
from ...base.channels import get_all_sales_channels
from ...helpers.compat import CompatDeleteView
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -189,9 +189,8 @@ def reorder_items(request, organizer, event):
return HttpResponse()
class CategoryDelete(EventPermissionRequiredMixin, DeleteView):
class CategoryDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = ItemCategory
form_class = CategoryForm
template_name = 'pretixcontrol/items/category_delete.html'
permission = 'can_change_items'
context_object_name = 'category'
@@ -523,7 +522,7 @@ def reorder_questions(request, organizer, event):
return HttpResponse()
class QuestionDelete(EventPermissionRequiredMixin, DeleteView):
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = Question
template_name = 'pretixcontrol/items/question_delete.html'
permission = 'can_change_items'
@@ -1090,7 +1089,7 @@ class QuotaUpdate(EventPermissionRequiredMixin, UpdateView):
return super().form_invalid(form)
class QuotaDelete(EventPermissionRequiredMixin, DeleteView):
class QuotaDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = Quota
template_name = 'pretixcontrol/items/quota_delete.html'
permission = 'can_change_items'
@@ -1204,7 +1203,7 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
initial['tax_rule'] = trs[0]
if self.copy_from:
fields = ('name', 'internal_name', 'category', 'admission', 'default_price', 'tax_rule')
fields = ('name', 'internal_name', 'category', 'admission', 'personalized', 'default_price', 'tax_rule')
for f in fields:
initial[f] = getattr(self.copy_from, f)
initial['copy_from'] = self.copy_from
@@ -1464,7 +1463,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
return f
class ItemDelete(EventPermissionRequiredMixin, DeleteView):
class ItemDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = Item
template_name = 'pretixcontrol/item/delete.html'
permission = 'can_change_items'
+3
View File
@@ -127,6 +127,9 @@ class OAuthApplicationDeleteView(ApplicationDelete):
self.object.save()
return HttpResponseRedirect(self.success_url)
def form_valid(self, form):
return self.delete(self.request, self.args, self.kwargs)
class AuthorizationListView(ListView):
template_name = 'pretixcontrol/oauth/authorized.html'
+207 -34
View File
@@ -77,7 +77,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedFile, CachedTicket, Checkin, Invoice,
InvoiceAddress, Item, ItemVariation, LogEntry, Order, QuestionAnswer,
Quota, generate_secret,
Quota, ScheduledEventExport, generate_secret,
)
from pretix.base.models.orders import (
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
@@ -87,7 +87,7 @@ from pretix.base.payment import PaymentException
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.cancelevent import cancel_event
from pretix.base.services.export import export
from pretix.base.services.export import export, scheduled_event_export
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task,
invoice_qualified, regenerate_invoice,
@@ -111,6 +111,7 @@ from pretix.base.templatetags.money import money_filter
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.exports import ScheduledEventExportForm
from pretix.control.forms.filter import (
EventOrderExpertFilterForm, EventOrderFilterForm, OverviewFilterForm,
RefundFilterForm,
@@ -122,9 +123,11 @@ from pretix.control.forms.orders import (
OrderPositionAddFormset, OrderPositionChangeForm, OrderPositionMailForm,
OrderRefundForm, OtherOperationsForm, ReactivateOrderForm,
)
from pretix.control.forms.rrule import RRuleForm
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import order_search_forms
from pretix.control.views import PaginationMixin
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.format import format_map
from pretix.helpers.safedownload import check_token
from pretix.presale.signals import question_form_fields
@@ -383,8 +386,8 @@ class OrderDetail(OrderView):
p.has_questions = (
p.additional_fields or
(p.item.admission and self.request.event.settings.attendee_names_asked) or
(p.item.admission and self.request.event.settings.attendee_emails_asked) or
(p.item.ask_attendee_data and self.request.event.settings.attendee_names_asked) or
(p.item.ask_attendee_data and self.request.event.settings.attendee_emails_asked) or
p.item.questions.all()
)
p.cache_answers()
@@ -2237,20 +2240,40 @@ class OrderGo(EventPermissionRequiredMixin, View):
class ExportMixin:
@cached_property
def exporters(self):
exporters = []
responses = register_data_exporters.send(self.request.event)
id = self.request.GET.get("identifier") or self.request.POST.get("exporter")
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
if id and ex.identifier != id:
return sorted(
[response(self.request.event, self.request.organizer) for r, response in responses if response],
key=lambda ex: (0 if ex.category else 1, ex.category or "", 0 if ex.featured else 1, str(ex.verbose_name).lower())
)
@cached_property
def exporter(self):
id = self.request.GET.get("identifier") or self.request.POST.get("exporter") or self.request.GET.get("exporter")
if not id:
return None
for ex in self.exporters:
if id != ex.identifier:
continue
# Use form parse cycle to generate useful defaults
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
test_form.is_valid()
initial = {
k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET
}
if self.scheduled:
initial = self.scheduled.export_form_data
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
for k in initial:
if initial[k] and k in test_form.fields:
try:
initial[k] = test_form.fields[k].to_python(initial[k])
except Exception:
pass
else:
# Use form parse cycle to generate useful defaults
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
test_form.is_valid()
initial = {
k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET
}
ex.form = ExporterForm(
data=(self.request.POST if self.request.method == 'POST' else None),
@@ -2258,12 +2281,27 @@ class ExportMixin:
initial=initial
)
ex.form.fields = ex.export_form_fields
exporters.append(ex)
return exporters
return ex
def get_scheduled_queryset(self):
if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_change_event_settings',
request=self.request):
qs = self.request.event.scheduled_exports.filter(owner=self.request.user)
else:
qs = self.request.event.scheduled_exports
return qs.select_related('owner').order_by('export_identifier', 'schedule_next_run')
@cached_property
def scheduled(self):
if "scheduled" in self.request.POST:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.POST.get("scheduled"))
elif "scheduled" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['exporters'] = self.exporters
ctx['exporter'] = self.exporter
return ctx
@@ -2271,7 +2309,7 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, Templ
permission = 'can_view_orders'
known_errortypes = ['ExportError']
task = export
template_name = 'pretixcontrol/orders/export.html'
template_name = 'pretixcontrol/orders/export_form.html'
def get_success_message(self, value):
return None
@@ -2288,16 +2326,6 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, Templ
def get_check_url(self, task_id, ajax):
return self.request.path + '?async_id=%s&exporter=%s' % (task_id, self.exporter.identifier) + ('&ajax=1' if ajax else '')
@cached_property
def exporter(self):
if self.request.method == "POST":
identifier = self.request.POST.get("exporter")
else:
identifier = self.request.GET.get("exporter")
for ex in self.exporters:
if ex.identifier == identifier:
return ex
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
@@ -2311,20 +2339,165 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, Templ
'organizer': self.request.event.organizer.slug
}))
if not self.exporter.form.is_valid():
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
return self.get(request, *args, **kwargs)
if self.scheduled:
data = self.scheduled.export_form_data
else:
if not self.exporter.form.is_valid():
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
return self.get(request, *args, **kwargs)
data = self.exporter.form.cleaned_data
cf = CachedFile(web_download=True, session_key=request.session.session_key)
cf.date = now()
cf.expires = now() + timedelta(hours=24)
cf.save()
return self.do(self.request.event.id, str(cf.id), self.exporter.identifier, self.exporter.form.cleaned_data)
return self.do(self.request.event.id, str(cf.id), self.exporter.identifier, data)
class ExportView(EventPermissionRequiredMixin, ExportMixin, TemplateView):
class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
permission = 'can_view_orders'
template_name = 'pretixcontrol/orders/export.html'
paginate_by = 25
context_object_name = 'scheduled'
def get_template_names(self):
if self.exporter:
return ['pretixcontrol/orders/export_form.html']
return ['pretixcontrol/orders/export.html']
@transaction.atomic()
def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save":
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
self.schedule_form.instance.export_identifier = self.exporter.identifier
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
self.schedule_form.instance.error_counter = 0
self.schedule_form.instance.error_last_message = None
self.schedule_form.instance.compute_next_run()
self.schedule_form.instance.save()
if self.schedule_form.instance.schedule_next_run:
messages.success(
request,
_('Your export schedule has been saved. The next export will start around {datetime}.').format(
datetime=date_format(self.schedule_form.instance.schedule_next_run, 'SHORT_DATETIME_FORMAT')
)
)
else:
messages.warning(request, _('Your export schedule has been saved, but no next export is planned.'))
self.request.event.log_action(
'pretix.event.export.schedule.changed' if self.scheduled else 'pretix.event.export.schedule.added',
user=self.request.user, data={
'id': self.schedule_form.instance.id,
'export_identifier': self.exporter.identifier,
'export_form_data': self.exporter.form.cleaned_data,
'schedule_rrule': self.schedule_form.instance.schedule_rrule,
**self.schedule_form.cleaned_data,
}
)
return redirect(reverse('control:event.orders.export', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug
}))
else:
return super().get(request, *args, **kwargs)
return super().get(request, *args, **kwargs)
@cached_property
def rrule_form(self):
if self.scheduled:
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
else:
initial = {}
return RRuleForm(
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
prefix="rrule",
initial=initial
)
@cached_property
def schedule_form(self):
instance = self.scheduled or ScheduledEventExport(
event=self.request.event,
owner=self.request.user,
)
if not self.scheduled:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
name=str(self.request.event.name)
),
"schedule_rrule_time": time(4, 0, 0),
}
else:
initial = {}
return ScheduledEventExportForm(
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
prefix="schedule",
instance=instance,
initial=initial,
)
def get_queryset(self):
return self.get_scheduled_queryset()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if "schedule" in self.request.POST or self.scheduled:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
elif not self.exporter:
for s in ctx['scheduled']:
try:
s.export_verbose_name = [e for e in self.exporters if e.identifier == s.export_identifier][0].verbose_name
except IndexError:
s.export_verbose_name = "?"
return ctx
class DeleteScheduledExportView(EventPermissionRequiredMixin, ExportMixin, CompatDeleteView):
permission = 'can_view_orders'
template_name = 'pretixcontrol/orders/export_delete.html'
context_object_name = 'export'
def get_queryset(self):
return self.get_scheduled_queryset()
def get_success_url(self):
return reverse('control:event.orders.export', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug
})
@transaction.atomic()
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
self.object.delete()
self.request.event.log_action('pretix.event.export.schedule.deleted', user=self.request.user, data={
'id': self.object.id,
})
return redirect(self.get_success_url())
class RunScheduledExportView(EventPermissionRequiredMixin, ExportMixin, View):
def post(self, request, *args, **kwargs):
s = get_object_or_404(self.get_scheduled_queryset(), pk=kwargs.get('pk'))
scheduled_event_export.apply_async(
kwargs={
'event': s.event_id,
'schedule': s.pk,
},
# Scheduled exports usually run on the low-prio queue "background" but if they're manually triggered,
# we run them with normal priority
queue='default',
)
messages.success(self.request, _('Your export is queued to start soon. The results will be send via email. '
'Depending on system load and type and size of export, this may take a few '
'minutes.'))
return redirect(reverse('control:event.orders.export', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug
}))
class RefundList(EventPermissionRequiredMixin, PaginationMixin, ListView):
+259 -66
View File
@@ -34,7 +34,7 @@
import json
import re
from datetime import timedelta
from datetime import time, timedelta
from decimal import Decimal
import bleach
@@ -53,9 +53,10 @@ from django.forms import DecimalField
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext, gettext_lazy as _
from django.views import View
from django.views.generic import (
CreateView, DeleteView, DetailView, FormView, ListView, TemplateView,
@@ -71,7 +72,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry,
Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer,
Team, TeamInvite, User,
ScheduledOrganizerExport, Team, TeamInvite, User,
)
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
@@ -81,12 +82,13 @@ from pretix.base.models.giftcards import (
from pretix.base.models.orders import CancellationRequest
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.payment import PaymentException
from pretix.base.services.export import multiexport
from pretix.base.services.export import multiexport, scheduled_organizer_export
from pretix.base.services.mail import SendMailException, mail
from pretix.base.settings import SETTINGS_AFFECTING_CSS
from pretix.base.signals import register_multievent_data_exporters
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.exports import ScheduledOrganizerExportForm
from pretix.control.forms.filter import (
CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm,
OrganizerFilterForm, TeamFilterForm,
@@ -100,6 +102,7 @@ from pretix.control.forms.organizer import (
OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm,
TeamForm, WebHookForm,
)
from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST
from pretix.control.permissions import (
AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin,
@@ -108,6 +111,7 @@ from pretix.control.signals import nav_organizer
from pretix.control.views import PaginationMixin
from pretix.control.views.mailsetup import MailSettingsSetupView
from pretix.helpers import GroupConcat
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.format import format_map
from pretix.helpers.urls import build_absolute_uri as build_global_uri
@@ -623,7 +627,7 @@ class TeamUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
return super().form_invalid(form)
class TeamDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
class TeamDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
model = Team
template_name = 'pretixcontrol/organizers/team_delete.html'
permission = 'can_change_teams'
@@ -859,12 +863,19 @@ class DeviceQueryMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
return self.request.POST
return self.request.GET
d = self.request.POST
else:
d = self.request.GET
d = d.copy()
d.setdefault('state', 'active')
return d
@cached_property
def filter_form(self):
return DeviceFilterForm(data=self.request_data, request=self.request)
return DeviceFilterForm(
data=self.request_data,
request=self.request,
)
def get_queryset(self):
qs = self.request.organizer.devices.prefetch_related(
@@ -1507,15 +1518,74 @@ class GiftCardUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
class ExportMixin:
@cached_property
def exporters(self):
exporters = []
events = self.request.user.get_events_with_permission('can_view_orders', request=self.request).filter(
def exporter(self):
id = self.request.GET.get("identifier") or self.request.POST.get("exporter") or self.request.GET.get("exporter")
if not id:
return None
for ex in self.exporters:
if id != ex.identifier:
continue
if self.scheduled:
initial = self.scheduled.export_form_data
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
for k in initial:
if initial[k] and k in test_form.fields:
try:
initial[k] = test_form.fields[k].to_python(initial[k])
except Exception:
pass
else:
# Use form parse cycle to generate useful defaults
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
test_form.is_valid()
initial = {
k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET
}
if 'events' not in initial:
initial.setdefault('all_events', True)
ex.form = ExporterForm(
data=(self.request.POST if self.request.method == 'POST' else None),
prefix=ex.identifier,
initial=initial
)
ex.form.fields = ex.export_form_fields
if not isinstance(ex, OrganizerLevelExportMixin):
ex.form.fields.update([
('all_events',
forms.BooleanField(
label=_("All events (that I have access to)"),
required=False
)),
('events',
forms.ModelMultipleChoiceField(
queryset=self.events,
widget=forms.CheckboxSelectMultiple(
attrs={
'class': 'scrolling-multiple-choice',
'data-inverse-dependency': f'#id_{ex.identifier}-all_events',
}
),
label=_('Events'),
required=False
)),
])
return ex
@cached_property
def events(self):
return self.request.user.get_events_with_permission('can_view_orders', request=self.request).filter(
organizer=self.request.organizer
)
@cached_property
def exporters(self):
responses = register_multievent_data_exporters.send(self.request.organizer)
id = self.request.GET.get("identifier") or self.request.POST.get("exporter")
raw_exporters = [
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer)
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events, self.request.organizer)
for r, response in responses
if response
]
@@ -1526,50 +1596,37 @@ class ExportMixin:
self.request.user.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
)
]
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
if id and ex.identifier != id:
continue
# Use form parse cycle to generate useful defaults
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
test_form.is_valid()
initial = {
k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET
}
ex.form = ExporterForm(
data=(self.request.POST if self.request.method == 'POST' else None),
prefix=ex.identifier,
initial=initial
)
ex.form.fields = ex.export_form_fields
if not isinstance(ex, OrganizerLevelExportMixin):
ex.form.fields.update([
('events',
forms.ModelMultipleChoiceField(
queryset=events,
initial=events,
widget=forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
label=_('Events'),
required=True
)),
])
exporters.append(ex)
return exporters
return sorted(
raw_exporters,
key=lambda ex: (0 if ex.category else 1, ex.category or "", 0 if ex.featured else 1, str(ex.verbose_name).lower())
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['exporter'] = self.exporter
ctx['exporters'] = self.exporters
return ctx
def get_scheduled_queryset(self):
if not self.request.user.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings',
request=self.request):
qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user)
else:
qs = self.request.organizer.scheduled_exports
return qs.select_related('owner').order_by('export_identifier', 'schedule_next_run')
@cached_property
def scheduled(self):
if "scheduled" in self.request.POST:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.POST.get("scheduled"))
elif "scheduled" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView):
known_errortypes = ['ExportError']
task = multiexport
template_name = 'pretixcontrol/organizers/export.html'
template_name = 'pretixcontrol/organizers/export_form.html'
def get_success_message(self, value):
return None
@@ -1582,12 +1639,6 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T
'organizer': self.request.organizer.slug
})
@cached_property
def exporter(self):
for ex in self.exporters:
if ex.identifier == self.request.POST.get("exporter"):
return ex
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
@@ -1600,9 +1651,13 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T
'organizer': self.request.organizer.slug
})
if not self.exporter.form.is_valid():
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
return self.get(request, *args, **kwargs)
if self.scheduled:
data = self.scheduled.export_form_data
else:
if not self.exporter.form.is_valid():
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
return self.get(request, *args, **kwargs)
data = self.exporter.form.cleaned_data
cf = CachedFile(web_download=True, session_key=request.session.session_key)
cf.date = now()
@@ -1615,13 +1670,151 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T
provider=self.exporter.identifier,
device=None,
token=None,
form_data=self.exporter.form.cleaned_data,
form_data=data,
staff_session=self.request.user.has_active_staff_session(self.request.session.session_key)
)
class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, TemplateView):
template_name = 'pretixcontrol/organizers/export.html'
class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
paginate_by = 25
context_object_name = 'scheduled'
def get_template_names(self):
if self.exporter:
return ['pretixcontrol/organizers/export_form.html']
return ['pretixcontrol/organizers/export.html']
@transaction.atomic()
def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save":
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
self.schedule_form.instance.export_identifier = self.exporter.identifier
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
self.schedule_form.instance.error_counter = 0
self.schedule_form.instance.error_last_message = None
self.schedule_form.instance.compute_next_run()
self.schedule_form.instance.save()
if self.schedule_form.instance.schedule_next_run:
messages.success(
request,
_('Your export schedule has been saved. The next export will start around {datetime}.').format(
datetime=date_format(self.schedule_form.instance.schedule_next_run, 'SHORT_DATETIME_FORMAT')
)
)
else:
messages.warning(request, _('Your export schedule has been saved, but no next export is planned.'))
self.request.organizer.log_action(
'pretix.organizer.export.schedule.changed' if self.scheduled else 'pretix.organizer.export.schedule.added',
user=self.request.user, data={
'id': self.schedule_form.instance.id,
'export_identifier': self.exporter.identifier,
'export_form_data': self.exporter.form.cleaned_data,
'schedule_rrule': self.schedule_form.instance.schedule_rrule,
**self.schedule_form.cleaned_data,
}
)
return redirect(reverse('control:organizer.export', kwargs={
'organizer': self.request.organizer.slug
}))
else:
return super().get(request, *args, **kwargs)
return super().get(request, *args, **kwargs)
@cached_property
def rrule_form(self):
if self.scheduled:
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
else:
initial = {}
return RRuleForm(
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
prefix="rrule",
initial=initial
)
@cached_property
def schedule_form(self):
instance = self.scheduled or ScheduledOrganizerExport(
organizer=self.request.organizer,
owner=self.request.user,
timezone=get_current_timezone().zone,
)
if not self.scheduled:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
name=str(self.request.organizer.name)
),
"schedule_rrule_time": time(4, 0, 0),
}
else:
initial = {}
return ScheduledOrganizerExportForm(
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
prefix="schedule",
instance=instance,
initial=initial,
)
def get_queryset(self):
return self.get_scheduled_queryset()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if "schedule" in self.request.POST or self.scheduled:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
elif not self.exporter:
for s in ctx['scheduled']:
try:
s.export_verbose_name = [e for e in self.exporters if e.identifier == s.export_identifier][0].verbose_name
except IndexError:
s.export_verbose_name = "?"
return ctx
class DeleteScheduledExportView(OrganizerPermissionRequiredMixin, ExportMixin, DeleteView):
template_name = 'pretixcontrol/organizers/export_delete.html'
context_object_name = 'export'
def get_queryset(self):
return self.get_scheduled_queryset()
def get_success_url(self):
return reverse('control:organizer.export', kwargs={
'organizer': self.request.organizer.slug
})
@transaction.atomic()
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
self.object.delete()
self.request.organizer.log_action('pretix.organizer.export.schedule.deleted', user=self.request.user, data={
'id': self.object.id,
})
return redirect(self.get_success_url())
class RunScheduledExportView(OrganizerPermissionRequiredMixin, ExportMixin, View):
def post(self, request, *args, **kwargs):
s = get_object_or_404(self.get_scheduled_queryset(), pk=kwargs.get('pk'))
scheduled_organizer_export.apply_async(
kwargs={
'organizer': s.organizer_id,
'schedule': s.pk,
},
# Scheduled exports usually run on the low-prio queue "background" but if they're manually triggered,
# we run them with normal priority
queue='default',
)
messages.success(self.request, _('Your export is queued to start soon. The results will be send via email. '
'Depending on system load and type and size of export, this may take a few '
'minutes.'))
return redirect(reverse('control:organizer.export', kwargs={
'organizer': self.request.organizer.slug
}))
class GateListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
@@ -1701,7 +1894,7 @@ class GateUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
return super().form_invalid(form)
class GateDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
class GateDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
model = Gate
template_name = 'pretixcontrol/organizers/gate_delete.html'
permission = 'can_change_organizer_settings'
@@ -1792,7 +1985,7 @@ class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionR
return super().form_invalid(form)
class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
model = EventMetaProperty
template_name = 'pretixcontrol/organizers/property_delete.html'
permission = 'can_change_organizer_settings'
@@ -1915,7 +2108,7 @@ class MembershipTypeUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
return super().form_invalid(form)
class MembershipTypeDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
class MembershipTypeDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
model = MembershipType
template_name = 'pretixcontrol/organizers/membershiptype_delete.html'
permission = 'can_change_organizer_settings'
@@ -2028,7 +2221,7 @@ class SSOProviderUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequire
return super().form_invalid(form)
class SSOProviderDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
class SSOProviderDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
model = CustomerSSOProvider
template_name = 'pretixcontrol/organizers/ssoprovider_delete.html'
permission = 'can_change_organizer_settings'
@@ -2156,7 +2349,7 @@ class SSOClientUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredM
return super().form_invalid(form)
class SSOClientDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
class SSOClientDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
model = CustomerSSOClient
template_name = 'pretixcontrol/organizers/ssoclient_delete.html'
permission = 'can_change_organizer_settings'
@@ -2419,7 +2612,7 @@ class MembershipUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequired
})
class MembershipDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
class MembershipDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
template_name = 'pretixcontrol/organizers/customer_membership_delete.html'
permission = 'can_manage_customers'
context_object_name = 'membership'
+6 -38
View File
@@ -36,7 +36,7 @@ import copy
from collections import defaultdict
from datetime import datetime, time, timedelta
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset
from dateutil.rrule import rruleset
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files import File
@@ -52,9 +52,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import make_aware
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.views import View
from django.views.generic import (
CreateView, DeleteView, FormView, ListView, UpdateView,
)
from django.views.generic import CreateView, FormView, ListView, UpdateView
from pretix.base.models import CartPosition, LogEntry
from pretix.base.models.checkin import CheckinList
@@ -80,6 +78,7 @@ from pretix.control.signals import subevent_forms
from pretix.control.views import PaginationMixin
from pretix.control.views.event import MetaDataEditorMixin
from pretix.helpers import GroupConcat
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.models import modelcopy
@@ -148,7 +147,7 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
return ctx
class SubEventDelete(EventPermissionRequiredMixin, DeleteView):
class SubEventDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = SubEvent
template_name = 'pretixcontrol/subevents/delete.html'
permission = 'can_change_settings'
@@ -789,41 +788,10 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
if f in self.rrule_formset.deleted_forms:
continue
rule_kwargs = {}
rule_kwargs['dtstart'] = f.cleaned_data['dtstart']
rule_kwargs['interval'] = f.cleaned_data['interval']
if f.cleaned_data['freq'] == 'yearly':
freq = YEARLY
if f.cleaned_data['yearly_same'] == "off":
rule_kwargs['bysetpos'] = int(f.cleaned_data['yearly_bysetpos'])
rule_kwargs['byweekday'] = f.parse_weekdays(f.cleaned_data['yearly_byweekday'])
rule_kwargs['bymonth'] = int(f.cleaned_data['yearly_bymonth'])
elif f.cleaned_data['freq'] == 'monthly':
freq = MONTHLY
if f.cleaned_data['monthly_same'] == "off":
rule_kwargs['bysetpos'] = int(f.cleaned_data['monthly_bysetpos'])
rule_kwargs['byweekday'] = f.parse_weekdays(f.cleaned_data['monthly_byweekday'])
elif f.cleaned_data['freq'] == 'weekly':
freq = WEEKLY
if f.cleaned_data['weekly_byweekday']:
rule_kwargs['byweekday'] = [f.parse_weekdays(a) for a in f.cleaned_data['weekly_byweekday']]
elif f.cleaned_data['freq'] == 'daily':
freq = DAILY
if f.cleaned_data['end'] == 'count':
rule_kwargs['count'] = f.cleaned_data['count']
else:
rule_kwargs['until'] = f.cleaned_data['until']
if f.cleaned_data['exclude']:
s.exrule(rrule(freq, **rule_kwargs))
s.exrule(f.to_rrule())
else:
s.rrule(rrule(freq, **rule_kwargs))
s.rrule(f.to_rrule())
return s
+7 -3
View File
@@ -400,9 +400,13 @@ def items_select2(request, **kwargs):
except ValueError:
page = 1
qs = request.event.items.filter(
name__icontains=i18ncomp(query)
).order_by(
q = Q(name__icontains=i18ncomp(query)) | Q(internal_name__icontains=query)
try:
if query.isdigit():
q |= Q(pk=int(query))
except ValueError:
pass
qs = request.event.items.filter(q).order_by(
F('category__position').asc(nulls_first=True),
'category',
'position',
+4 -3
View File
@@ -53,7 +53,7 @@ from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views.generic import (
CreateView, DeleteView, ListView, TemplateView, UpdateView, View,
CreateView, ListView, TemplateView, UpdateView, View,
)
from pretix.base.models import CartPosition, LogEntry, Voucher
@@ -66,6 +66,7 @@ from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import voucher_form_class
from pretix.control.views import PaginationMixin
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.models import modelcopy
@@ -182,7 +183,7 @@ class VoucherTags(EventPermissionRequiredMixin, TemplateView):
return VoucherTagFilterForm(data=self.request.GET, event=self.request.event)
class VoucherDeleteCarts(EventPermissionRequiredMixin, DeleteView):
class VoucherDeleteCarts(EventPermissionRequiredMixin, CompatDeleteView):
model = Voucher
template_name = 'pretixcontrol/vouchers/delete_carts.html'
permission = 'can_change_vouchers'
@@ -214,7 +215,7 @@ class VoucherDeleteCarts(EventPermissionRequiredMixin, DeleteView):
})
class VoucherDelete(EventPermissionRequiredMixin, DeleteView):
class VoucherDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = Voucher
template_name = 'pretixcontrol/vouchers/delete.html'
permission = 'can_change_vouchers'
+2 -2
View File
@@ -48,7 +48,6 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext
from django.views import View
from django.views.generic import ListView
from django.views.generic.edit import DeleteView
from pretix.base.models import Item, Quota, WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException
@@ -58,6 +57,7 @@ from pretix.control.forms.waitinglist import WaitingListEntryTransferForm
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views import PaginationMixin
from ...helpers.compat import CompatDeleteView
from . import UpdateView
@@ -338,7 +338,7 @@ class WaitingListView(EventPermissionRequiredMixin, WaitingListQuerySetMixin, Pa
return '{}_waitinglist'.format(self.request.event.slug)
class EntryDelete(EventPermissionRequiredMixin, DeleteView):
class EntryDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = WaitingListEntry
template_name = 'pretixcontrol/waitinglist/delete.html'
permission = 'can_change_orders'
+30
View File
@@ -22,9 +22,39 @@
import datetime
import sys
from django.forms import Form
from django.views.generic.detail import (
BaseDetailView, SingleObjectTemplateResponseMixin,
)
from django.views.generic.edit import DeletionMixin, FormMixin
def date_fromisocalendar(isoyear, isoweek, isoday):
if sys.version_info < (3, 8):
return datetime.datetime.strptime(f'{isoyear}-W{isoweek}-{isoday}', "%G-W%V-%u")
else:
return datetime.datetime.fromisocalendar(isoyear, isoweek, isoday)
class CompatDeleteView(SingleObjectTemplateResponseMixin, DeletionMixin, FormMixin, BaseDetailView):
"""
This is a DeleteView variant that allows us to create subclasses that work on both Django 3.2 and
Django 4.0.
"""
form_class = Form
template_name_suffix = "_confirm_delete"
def post(self, request, *args, **kwargs):
# Set self.object before the usual form processing flow.
# Inlined because having DeletionMixin as the first base, for
# get_success_url(), makes leveraging super() with ProcessFormView
# overly complex.
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
return self.delete(self.request, self.args, self.kwargs)
+40 -1
View File
@@ -22,7 +22,7 @@
import contextlib
from django.db import transaction
from django.db.models import Aggregate, Field, Lookup
from django.db.models import Aggregate, Expression, Field, Lookup, Value
class DummyRollbackException(Exception):
@@ -103,3 +103,42 @@ class NotEqual(Lookup):
rhs, rhs_params = self.process_rhs(compiler, connection)
params = lhs_params + rhs_params
return '%s <> %s' % (lhs, rhs), params
class PostgresWindowFrame(Expression):
template = "%(frame_type)s BETWEEN %(start)s AND %(end)s"
def __init__(self, frame_type=None, start=None, end=None):
self.frame_type = frame_type
self.start = Value(start)
self.end = Value(end)
def set_source_expressions(self, exprs):
self.start, self.end = exprs
def get_source_expressions(self):
return [self.start, self.end]
def as_sql(self, compiler, connection):
return (
self.template
% {
"frame_type": self.frame_type,
"start": self.start.value,
"end": self.end.value,
},
[],
)
def __repr__(self):
return "<%s: %s>" % (self.__class__.__name__, self)
def get_group_by_cols(self, alias=None):
return []
def __str__(self):
return self.template % {
"frame_type": self.frame_type,
"start": self.start.value,
"end": self.end.value,
}
+85
View File
@@ -0,0 +1,85 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
from io import BytesIO
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from PIL.Image import MAX_IMAGE_PIXELS, DecompressionBombError
IMAGE_TYPES = {'image/gif', 'image/jpeg', 'image/png'}
IMAGE_EXTS = {'.gif', '.jpg', '.jpeg', '.png'}
logger = logging.getLogger(__name__)
def validate_uploaded_file_for_valid_image(f):
if f is None:
return None
from PIL import Image
# We need to get a file object for Pillow. We might have a path or we might
# have to read the data into memory.
if hasattr(f, 'temporary_file_path'):
file = f.temporary_file_path()
else:
if hasattr(f, 'read'):
file = BytesIO(f.read())
else:
file = BytesIO(f['content'])
try:
try:
image = Image.open(file)
# verify() must be called immediately after the constructor.
image.verify()
except DecompressionBombError:
raise ValidationError(_(
"The file you uploaded has a very large number of pixels, please upload a picture with smaller dimensions."
))
# load() is a potential DoS vector (see Django bug #18520), so we verify the size first
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValidationError(_(
"The file you uploaded has a very large number of pixels, please upload a picture with smaller dimensions."
))
except Exception as exc:
logger.exception('Could not parse image')
# Pillow doesn't recognize it as an image.
if isinstance(exc, ValidationError):
raise
raise ValidationError(_(
"Upload a valid image. The file you uploaded was either not an image or a corrupted image."
)) from exc
if hasattr(f, 'seek') and callable(f.seek):
f.seek(0)
class ImageSizeValidator:
def __call__(self, image):
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValidationError(_(
"The file you uploaded has a very large number of pixels, please upload a picture with smaller dimensions."
))
return image
File diff suppressed because it is too large Load Diff
+57 -49
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-12-14 13:09+0000\n"
"POT-Creation-Date: 2023-01-19 10:47+0000\n"
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -132,18 +132,18 @@ msgstr ""
msgid "Mercado Pago"
msgstr ""
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:163
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:164
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:48
msgid "Continue"
msgstr "المتابعة"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:221
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:152
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:183
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:222
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:157
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:188
msgid "Confirming your payment …"
msgstr "جاري تأكيد الدفع الخاص بك …"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:246
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:247
msgid "Payment method unavailable"
msgstr ""
@@ -165,11 +165,11 @@ msgstr "إجمالي الإيرادات"
msgid "Contacting Stripe …"
msgstr "جاري الاتصال بStripe …"
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:60
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:65
msgid "Total"
msgstr "المجموع"
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:159
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:164
msgid "Contacting your bank …"
msgstr "جاري الاتصال بالبنك الذي تتعامل معه …"
@@ -367,11 +367,11 @@ msgid ""
msgstr ""
"لا يمكننا الوصول إلى الخادم حاليا، حاول مرة أخرى من فضلك. رمز الخطأ : {code}"
#: pretix/static/pretixbase/js/asynctask.js:208
#: pretix/static/pretixbase/js/asynctask.js:215
msgid "We are processing your request …"
msgstr "جاري معالجة طلبك …"
#: pretix/static/pretixbase/js/asynctask.js:216
#: pretix/static/pretixbase/js/asynctask.js:223
msgid ""
"We are currently sending your request to the server. If this takes longer "
"than one minute, please check your internet connection and then reload this "
@@ -380,7 +380,7 @@ msgstr ""
"نعمل الآن على ارسال طلبك إلى الخادم، إذا أستغرقت العملية أكثر من دقيقة، يرجى "
"التحقق من اتصالك بالإنترنت ثم أعد تحميل الصفحة مرة أخرى."
#: pretix/static/pretixbase/js/asynctask.js:276
#: pretix/static/pretixbase/js/asynctask.js:285
#: pretix/static/pretixcontrol/js/ui/main.js:71
msgid "Close message"
msgstr "أغلق الرسالة"
@@ -486,48 +486,48 @@ msgstr "الدقائق"
msgid "Check-in QR"
msgstr "QR الدخول"
#: pretix/static/pretixcontrol/js/ui/editor.js:384
#: pretix/static/pretixcontrol/js/ui/editor.js:385
msgid "The PDF background file could not be loaded for the following reason:"
msgstr "لا يمكن تحميل ملف PDF الخلفية للأسباب التالية:"
#: pretix/static/pretixcontrol/js/ui/editor.js:653
#: pretix/static/pretixcontrol/js/ui/editor.js:654
msgid "Group of objects"
msgstr "مجموعة من العناصر"
#: pretix/static/pretixcontrol/js/ui/editor.js:659
#: pretix/static/pretixcontrol/js/ui/editor.js:660
msgid "Text object"
msgstr "عنصر نص"
#: pretix/static/pretixcontrol/js/ui/editor.js:661
#: pretix/static/pretixcontrol/js/ui/editor.js:662
msgid "Barcode area"
msgstr "منطقة باركود"
#: pretix/static/pretixcontrol/js/ui/editor.js:663
#: pretix/static/pretixcontrol/js/ui/editor.js:664
msgid "Image area"
msgstr "منطقة صورة"
#: pretix/static/pretixcontrol/js/ui/editor.js:665
#: pretix/static/pretixcontrol/js/ui/editor.js:666
msgid "Powered by pretix"
msgstr "مدعوم من pretix"
#: pretix/static/pretixcontrol/js/ui/editor.js:667
#: pretix/static/pretixcontrol/js/ui/editor.js:668
msgid "Object"
msgstr "عنصر"
#: pretix/static/pretixcontrol/js/ui/editor.js:671
#: pretix/static/pretixcontrol/js/ui/editor.js:672
msgid "Ticket design"
msgstr "تصميم التذكرة"
#: pretix/static/pretixcontrol/js/ui/editor.js:961
#: pretix/static/pretixcontrol/js/ui/editor.js:962
msgid "Saving failed."
msgstr "فشلت عملية الحفظ."
#: pretix/static/pretixcontrol/js/ui/editor.js:1011
#: pretix/static/pretixcontrol/js/ui/editor.js:1050
#: pretix/static/pretixcontrol/js/ui/editor.js:1030
#: pretix/static/pretixcontrol/js/ui/editor.js:1069
msgid "Error while uploading your PDF file, please try again."
msgstr "حصل خطأ أثناء رفع ملف PDF الخاص بك، يرجى المحاولة مرة أخرى."
#: pretix/static/pretixcontrol/js/ui/editor.js:1035
#: pretix/static/pretixcontrol/js/ui/editor.js:1054
msgid "Do you really want to leave the editor without saving your changes?"
msgstr "هل تريد أن تغادر المحرر دون حفظ التعديلات؟"
@@ -557,31 +557,31 @@ msgid ""
"darker shade."
msgstr "تباين اللون سيئ للخلفية البيضاء، الرجاء اختيار لون غامق."
#: pretix/static/pretixcontrol/js/ui/main.js:454
#: pretix/static/pretixcontrol/js/ui/main.js:463
msgid "All"
msgstr "الكل"
#: pretix/static/pretixcontrol/js/ui/main.js:455
#: pretix/static/pretixcontrol/js/ui/main.js:464
msgid "None"
msgstr "لا شيء"
#: pretix/static/pretixcontrol/js/ui/main.js:456
#: pretix/static/pretixcontrol/js/ui/main.js:465
msgid "Search query"
msgstr "البحث في الاستفسارات"
#: pretix/static/pretixcontrol/js/ui/main.js:459
#: pretix/static/pretixcontrol/js/ui/main.js:468
msgid "Selected only"
msgstr "المختارة فقط"
#: pretix/static/pretixcontrol/js/ui/main.js:862
#: pretix/static/pretixcontrol/js/ui/main.js:871
msgid "Use a different name internally"
msgstr "قم باستخدم اسم مختلف داخليا"
#: pretix/static/pretixcontrol/js/ui/main.js:898
#: pretix/static/pretixcontrol/js/ui/main.js:907
msgid "Click to close"
msgstr "اضغط لاغلاق الصفحة"
#: pretix/static/pretixcontrol/js/ui/main.js:970
#: pretix/static/pretixcontrol/js/ui/main.js:979
msgid "You have unsaved changes!"
msgstr "لم تقم بحفظ التعديلات!"
@@ -866,85 +866,93 @@ msgid "Open seat selection"
msgstr "خيارات المقاعد"
#: pretix/static/pretixpresale/js/widget/widget.js:56
msgctxt "widget"
msgid ""
"Some or all ticket categories are currently sold out. If you want, you can "
"add yourself to the waiting list. We will then notify if seats are available "
"again."
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js:57
#, fuzzy
#| msgid "Load more"
msgctxt "widget"
msgid "Load more"
msgstr "تحميل المزيد"
#: pretix/static/pretixpresale/js/widget/widget.js:58
#: pretix/static/pretixpresale/js/widget/widget.js:59
msgid "Mo"
msgstr "الإثنين"
#: pretix/static/pretixpresale/js/widget/widget.js:59
#: pretix/static/pretixpresale/js/widget/widget.js:60
msgid "Tu"
msgstr "الثلاثاء"
#: pretix/static/pretixpresale/js/widget/widget.js:60
#: pretix/static/pretixpresale/js/widget/widget.js:61
msgid "We"
msgstr "الأربعاء"
#: pretix/static/pretixpresale/js/widget/widget.js:61
#: pretix/static/pretixpresale/js/widget/widget.js:62
msgid "Th"
msgstr "الخميس"
#: pretix/static/pretixpresale/js/widget/widget.js:62
#: pretix/static/pretixpresale/js/widget/widget.js:63
msgid "Fr"
msgstr "الجمعة"
#: pretix/static/pretixpresale/js/widget/widget.js:63
#: pretix/static/pretixpresale/js/widget/widget.js:64
msgid "Sa"
msgstr "السبت"
#: pretix/static/pretixpresale/js/widget/widget.js:64
#: pretix/static/pretixpresale/js/widget/widget.js:65
msgid "Su"
msgstr "الأحد"
#: pretix/static/pretixpresale/js/widget/widget.js:67
#: pretix/static/pretixpresale/js/widget/widget.js:68
msgid "January"
msgstr "يناير"
#: pretix/static/pretixpresale/js/widget/widget.js:68
#: pretix/static/pretixpresale/js/widget/widget.js:69
msgid "February"
msgstr "فبراير"
#: pretix/static/pretixpresale/js/widget/widget.js:69
#: pretix/static/pretixpresale/js/widget/widget.js:70
msgid "March"
msgstr "مارس"
#: pretix/static/pretixpresale/js/widget/widget.js:70
#: pretix/static/pretixpresale/js/widget/widget.js:71
msgid "April"
msgstr "أبريل"
#: pretix/static/pretixpresale/js/widget/widget.js:71
#: pretix/static/pretixpresale/js/widget/widget.js:72
msgid "May"
msgstr "مايو"
#: pretix/static/pretixpresale/js/widget/widget.js:72
#: pretix/static/pretixpresale/js/widget/widget.js:73
msgid "June"
msgstr "يونيو"
#: pretix/static/pretixpresale/js/widget/widget.js:73
#: pretix/static/pretixpresale/js/widget/widget.js:74
msgid "July"
msgstr "يوليو"
#: pretix/static/pretixpresale/js/widget/widget.js:74
#: pretix/static/pretixpresale/js/widget/widget.js:75
msgid "August"
msgstr "أغسطس"
#: pretix/static/pretixpresale/js/widget/widget.js:75
#: pretix/static/pretixpresale/js/widget/widget.js:76
msgid "September"
msgstr "سبتمبر"
#: pretix/static/pretixpresale/js/widget/widget.js:76
#: pretix/static/pretixpresale/js/widget/widget.js:77
msgid "October"
msgstr "أكتوبر"
#: pretix/static/pretixpresale/js/widget/widget.js:77
#: pretix/static/pretixpresale/js/widget/widget.js:78
msgid "November"
msgstr "نوفمبر"
#: pretix/static/pretixpresale/js/widget/widget.js:78
#: pretix/static/pretixpresale/js/widget/widget.js:79
msgid "December"
msgstr "ديسمبر"
File diff suppressed because it is too large Load Diff

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