Compare commits

..

199 Commits

Author SHA1 Message Date
Raphael Michel
e8f3a66a8e Add signal pretix.control.signals.event_dashboard_top 2020-09-06 17:25:47 +02:00
Raphael Michel
d999971249 Allow to disable self-choice seating 2020-09-06 17:25:47 +02:00
Raphael Michel
fb701f25f4 Merge pull request #1761 from pretix-translations/weblate-pretix-pretix 2020-09-04 15:40:20 +02:00
Svyatoslav
913596459a Translated on translate.pretix.eu (Russian)
Currently translated at 29.8% (1114 of 3733 strings)

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

powered by weblate
2020-09-04 05:01:43 +02:00
Svyatoslav
04e9ea1ae7 Translated on translate.pretix.eu (Latvian)
Currently translated at 29.5% (1100 of 3733 strings)

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

powered by weblate
2020-09-04 05:01:43 +02:00
Maarten van den Berg
5dc09019ff Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3733 of 3733 strings)

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

powered by weblate
2020-09-04 05:01:43 +02:00
Maarten van den Berg
7c3671b383 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3733 of 3733 strings)

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

powered by weblate
2020-09-04 05:01:43 +02:00
Raphael Michel
b91c16538e Improve mobile shopping cart experience 2020-09-04 05:01:22 +02:00
Raphael Michel
9ba517109d Allow to inspect logs by device 2020-09-03 14:40:19 +02:00
Raphael Michel
5f86fbc21d Allow to filter event log by device 2020-09-03 14:30:21 +02:00
Raphael Michel
fae35cc56f Improve error handling of check-in scans 2020-09-03 14:30:18 +02:00
Raphael Michel
78c5eb4516 Merge branch 'master' of github.com:pretix/pretix 2020-09-02 18:58:22 +02:00
Raphael Michel
860f4c36a4 Name length validation 2020-09-02 18:13:42 +02:00
Martin Gross
311dcfaab0 Add notice that list-view is not available for eventseries with more than 100 dates. 2020-09-02 15:13:59 +02:00
Martin Gross
83a6041a32 Expose Order Time in OrderData-Export 2020-09-02 11:07:50 +02:00
Raphael Michel
a2f9bb73ad Fix failing tests 2020-09-01 22:13:19 +02:00
Raphael Michel
fb92c9dd64 Remove obsolete restriction from documentation 2020-09-01 21:52:03 +02:00
Raphael Michel
aa1910fd70 Add pseudonymization ID to search fields 2020-09-01 16:51:07 +02:00
Raphael Michel
f66c266ff7 Fix debugging code 2020-09-01 15:53:56 +02:00
Raphael Michel
7cc5179e85 Fix #1040 -- Work around firefox bug in widget 2020-09-01 15:46:31 +02:00
Raphael Michel
f633cc3103 Fix errors around subevent editing 2020-09-01 15:06:03 +02:00
Raphael Michel
5e212c83e4 Add Kosovo to country list 2020-08-31 13:23:13 +02:00
Raphael Michel
2d5768aa20 Fix missing fields in CheckinListOrderPositionSerializer 2020-08-29 12:38:23 +02:00
Martin Gross
8ea66bc05b First try at working around Stripe's iDEAL idempotency issues 2020-08-28 23:21:35 +02:00
Raphael Michel
eba17e22fb Show beneficiary on order confirmation page 2020-08-27 14:13:31 +02:00
Raphael Michel
620c956ef8 Delete vouchers when deleting events 2020-08-27 12:41:12 +02:00
Raphael Michel
35debba865 Further attempt at more efificent query 2020-08-26 16:42:34 +02:00
Raphael Michel
a635ea527e Fix failing tests 2020-08-26 16:33:31 +02:00
Raphael Michel
6e76db40ed Order API: More efficient query for ?subevent_after_qs= 2020-08-26 15:43:22 +02:00
Raphael Michel
7956074d8b API: Add exclude parameter to check-in lists 2020-08-26 15:20:44 +02:00
Raphael Michel
7a3418e32f SubEvent: Automatically bump all orders when date is changed 2020-08-26 11:00:43 +02:00
Raphael Michel
0bfc436970 Check-in list: Redirect back toe diting after save 2020-08-25 16:19:11 +02:00
Raphael Michel
b6fc02255d Allow to clone check-in lists 2020-08-25 15:52:46 +02:00
Raphael Michel
a06f94fde1 Clarify "disabled" checkbox 2020-08-25 14:08:27 +02:00
Raphael Michel
d5073f416c Merge pull request #1757 from pretix-translations/weblate-pretix-pretix 2020-08-25 12:20:32 +02:00
Dennis Lichtenthäler
b32ea0dec4 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3733 of 3733 strings)

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

powered by weblate
2020-08-25 12:15:52 +02:00
Dennis Lichtenthäler
932851cf96 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3733 of 3733 strings)

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

powered by weblate
2020-08-25 12:15:51 +02:00
Dennis Lichtenthäler
4513cd7ec3 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (128 of 128 strings)

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

powered by weblate
2020-08-25 11:54:59 +02:00
Dennis Lichtenthäler
261878b3fe Translated on translate.pretix.eu (German)
Currently translated at 100.0% (128 of 128 strings)

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

powered by weblate
2020-08-25 11:54:59 +02:00
Dennis Lichtenthäler
31590f7e6c Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3733 of 3733 strings)

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

powered by weblate
2020-08-25 11:54:59 +02:00
Dennis Lichtenthäler
ebe7560f14 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3733 of 3733 strings)

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

powered by weblate
2020-08-25 11:54:58 +02:00
Raphael Michel
c5b722ebc1 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-08-25 11:54:50 +02:00
Raphael Michel
5ea961819d Remove field Seat.name 2020-08-25 11:54:19 +02:00
Martin Gross
983d734c6a Add .swi to allowed MT940 file extensions 2020-08-25 11:30:51 +02:00
Raphael Michel
c1bca2f207 isort fix 2020-08-24 17:37:38 +02:00
Raphael Michel
118f0f55e9 Widget: Do not disable products with require_voucher if a voucher is
given
2020-08-24 17:31:57 +02:00
Raphael Michel
d1146add38 Allow to re-check-in someone through the backend 2020-08-24 17:27:06 +02:00
Raphael Michel
fc18788cb8 Order API: Add `subevent_after` query filter 2020-08-21 19:06:05 +02:00
Raphael Michel
a2eb4444b4 Order API: Add `exclude` query parameter 2020-08-21 18:38:24 +02:00
Raphael Michel
606d13e303 Check-in list API: Add `subevent_match` filter 2020-08-21 17:20:37 +02:00
Raphael Michel
d90fcee5e1 Fix crash related to vouchers and seats
PRETIXEU-2PY
2020-08-21 16:12:04 +02:00
Raphael Michel
e9a4c3845a Fix crash when processing refund for empty order 2020-08-21 16:07:52 +02:00
Raphael Michel
018fac2361 Merge pull request #1756 from pretix/felix-patch 2020-08-21 15:19:45 +02:00
Raphael Michel
41dd71879e Allow to filter items with query parameters on event page 2020-08-21 15:18:37 +02:00
Felix Rindt
738e5d07aa reorder signal docs 2020-08-20 20:42:31 +02:00
Felix Rindt
a22451140b fix typo 2020-08-20 20:42:23 +02:00
Raphael Michel
6759506838 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-08-20 13:52:24 +02:00
Raphael Michel
82bb3f3b6e RelativeDate: Allow to specify "minutes before x" 2020-08-20 13:51:55 +02:00
Raphael Michel
cdb8a92a47 Merge pull request #1754 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-08-20 13:49:28 +02:00
Svyatoslav
7597344897 Translated on translate.pretix.eu (Russian)
Currently translated at 29.8% (1110 of 3731 strings)

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

powered by weblate
2020-08-19 11:30:39 +02:00
Raphael Michel
c94d384e86 Improve algorithm for {name} placeholder (#1745)
Co-authored-by: Felix Rindt <felix@rindt.me>
2020-08-19 11:30:34 +02:00
Raphael Michel
b2357b7e29 Merge pull request #1751 from pretix-translations/weblate-pretix-pretix 2020-08-19 11:30:02 +02:00
Raphael Michel
c7d1e5d069 Allow to reduce the interval of some cronjobs (#1753) 2020-08-19 11:29:53 +02:00
Svyatoslav
754d498938 Translated on translate.pretix.eu (Russian)
Currently translated at 29.5% (1102 of 3731 strings)

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

powered by weblate
2020-08-18 18:21:44 +02:00
Maarten van den Berg
ec7fc05108 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3731 of 3731 strings)

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

powered by weblate
2020-08-18 17:34:51 +02:00
Maarten van den Berg
bbba0df6c4 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3731 of 3731 strings)

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

powered by weblate
2020-08-18 17:34:51 +02:00
Maarten van den Berg
65e87455ec Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 98.5% (3674 of 3731 strings)

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

powered by weblate
2020-08-18 17:34:51 +02:00
Maarten van den Berg
e6d09baacc Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3731 of 3731 strings)

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

powered by weblate
2020-08-18 17:34:51 +02:00
Raphael Michel
fbd38fef58 Fix issue in previous commit 2020-08-18 17:34:39 +02:00
Raphael Michel
253f944951 Quota cache refresh: work in chunks 2020-08-18 16:46:37 +02:00
Raphael Michel
7f30f753d7 Actually do not show date on invoices if not shown on frontpage 2020-08-18 13:57:35 +02:00
Raphael Michel
8789a42dc1 Fix tax calculation for negative fees 2020-08-17 15:56:03 +02:00
Raphael Michel
e7740b1735 Fix crash on addons without tax rule
PRETIXEU-2MZ
2020-08-17 09:39:59 +02:00
Raphael Michel
586da71a64 Remove Raphael's personal mail address from the README 2020-08-16 19:21:24 +02:00
Martin Gross
68697f0c6a Update po files
[CI skip]

Signed-off-by: Martin Gross <gross@rami.io>
2020-08-13 14:29:53 +02:00
pretix translation bot
a2e1bc9c20 Translations update from Weblate (#1744)
Co-authored-by: Maarten van den Berg <maartenberg1@gmail.com>
Co-authored-by: Martin Gross <martin@pc-coholic.de>
2020-08-13 14:26:14 +02:00
Martin Gross
89a82d75a9 Add eMail-Template for free approval orders (#1750)
Co-authored-by: Felix Rindt <felix@rindt.me>
2020-08-13 14:24:39 +02:00
Martin Gross
a2414081af Widget: Do not preset item quantity to 1 if there is only one item but an active seating plan 2020-08-13 11:21:24 +02:00
Martin Gross
c812d39b39 Filter BankImportJobs explicitly by organizer 2020-08-13 11:10:27 +02:00
Martin Gross
c6f3fdd8e4 Move reserved explanation out of tooltip 2020-08-12 16:37:59 +02:00
Martin Gross
30e0f5ebc7 Show seat in checkout_question-fragment when adequate 2020-08-11 17:54:52 +02:00
Martin Gross
f767f2f644 Fix encoding of Umlauts in widget (and hopefully don't break it...) 2020-08-10 17:23:05 +02:00
Martin Gross
750c3c5201 Allow for gt and gte selection of change_allow_user_price (#1746) 2020-08-07 11:54:27 +02:00
Raphael Michel
7d9220ae3e Fix issue in 69879bdae 2020-08-06 10:21:57 +02:00
Raphael Michel
69879bdae0 Fix API bug: Do not delete SubEventItems on PATCH request 2020-08-06 09:28:35 +02:00
Raphael Michel
0e245b41ee Fix duplicate call of form_success 2020-08-05 15:18:40 +02:00
Raphael Michel
2839ee1ffd Fix error in b6f47f6f4 2020-08-05 14:41:12 +02:00
Raphael Michel
d72a03c434 Allow to adjust ticket cache duration 2020-08-05 13:23:20 +02:00
Raphael Michel
b6f47f6f4a API: More validation in custom fields on event serializers 2020-08-05 11:26:11 +02:00
Raphael Michel
ca2dd0d6b6 Limit maximum length of event names in email senders 2020-08-05 11:23:27 +02:00
Raphael Michel
c4415beb8c Force Django version to be at least 3.0.9 2020-08-05 11:22:35 +02:00
Raphael Michel
35c8684cd4 Prevent issues with order fees and TaxRule.zero() 2020-08-04 14:07:26 +02:00
Raphael Michel
9bb5c57792 Fix possible crash in migration 2020-08-04 11:47:55 +02:00
Felix Rindt
1c8699662d Allow to create invoices before bank transfer runs (#1734)
Co-authored-by: Raphael Michel <michel@rami.io>
2020-08-04 10:53:59 +02:00
Felix Rindt
9b367cb28b Allow to set multiple confirm texts (#1735) 2020-08-04 10:20:55 +02:00
Felix Rindt
896ba5b06b Fix #1740 - Do not group gift card positions (#1743)
Co-authored-by: Raphael Michel <michel@rami.io>
2020-08-04 10:18:04 +02:00
Raphael Michel
8f3dbba859 PDF renderer: Fail silently if bidirectional string handling failes 2020-08-03 18:20:03 +02:00
Felix Rindt
bf5b92c465 Copy answers button for addon products (#1733) 2020-08-03 18:15:23 +02:00
Raphael Michel
aef09003d9 Make global_html_* signals actually global 2020-08-03 12:32:29 +02:00
Raphael Michel
9d22e833a6 Merge pull request #1737 from pretix-translations/weblate-pretix-pretix 2020-07-31 09:25:00 +02:00
Abdullah
1e121c0f75 Translated on translate.pretix.eu (Arabic)
Currently translated at 78.1% (100 of 128 strings)

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

powered by weblate
2020-07-31 09:24:49 +02:00
Abdullah
373755a502 Translated on translate.pretix.eu (Arabic)
Currently translated at 87.6% (3260 of 3720 strings)

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

powered by weblate
2020-07-31 09:24:49 +02:00
pajowu
6f694b60ca Add minimum postgres version to docs (#1738) 2020-07-31 09:24:43 +02:00
Felix Rindt
77f76195c8 isort 5.0 config/docs (#1736) 2020-07-30 17:57:26 +02:00
Raphael Michel
355dd4463b Fix typo in docs 2020-07-30 17:57:10 +02:00
Raphael Michel
c0c39223aa Addendum to c15344ced 2020-07-30 17:47:05 +02:00
Raphael Michel
db7f8d9658 Try to run apt-get update before installations 2020-07-30 16:33:14 +02:00
Raphael Michel
c15344ced2 Docker: Pass environment variables when calling supervisord 2020-07-30 16:22:37 +02:00
Raphael Michel
0f3f15a736 Upgrade requests version 2020-07-29 18:30:25 +02:00
Raphael Michel
478f6e3029 Add a !default command to our _variables.scss 2020-07-29 18:30:25 +02:00
Raphael Michel
4c77e2f16e Add signals global_html_head, global_html_page_header, and global_html_footer 2020-07-29 18:30:25 +02:00
Felix Rindt
80b6a3d27d Fix #1675 -- Allow '0' as answer to number questions (#1732)
Co-authored-by: Raphael Michel <michel@rami.io>
2020-07-28 16:32:06 +02:00
Raphael Michel
89e8d3d12f Allow to disable some e-mails depending on sales channel (#1726)
Co-authored-by: Raphael Michel <michel@rami.io>
2020-07-28 09:26:18 +02:00
Raphael Michel
baf8a4ae18 Update src/pretix/control/forms/event.py 2020-07-28 09:25:10 +02:00
Raphael Michel
2cdaf07c46 Update src/pretix/control/forms/event.py 2020-07-28 09:24:53 +02:00
Raphael Michel
cf76a2e24d Fix typo in docs 2020-07-28 09:23:44 +02:00
Raphael Michel
559b4a8e66 Merge pull request #1730 from pretix-translations/weblate-pretix-pretix 2020-07-27 18:04:19 +02:00
Abdullah
59bf11b98d Translated on translate.pretix.eu (Arabic)
Currently translated at 87.6% (3257 of 3720 strings)

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

powered by weblate
2020-07-27 18:03:45 +02:00
Yaling
5b3551fb60 Translated on translate.pretix.eu (Chinese (Simplified))
Currently translated at 82.8% (3081 of 3720 strings)

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

powered by weblate
2020-07-27 18:03:45 +02:00
Raphael Michel
72a5008513 Allow to remove a product from all sales channels 2020-07-27 18:03:26 +02:00
Raphael Michel
c5ace8447d Fix country fields always being required 2020-07-27 18:03:10 +02:00
Raphael Michel
28b82841c2 Use edgecase words correctly 2020-07-27 17:55:19 +02:00
Raphael Michel
fbe10a981b Add INV to edgecase words 2020-07-27 17:47:31 +02:00
Raphael Michel
21c22aa63a Revert "Next attempt to fix spellcheck"
This reverts commit fb4116012f.
2020-07-27 17:47:15 +02:00
Raphael Michel
fb4116012f Next attempt to fix spellcheck 2020-07-27 16:28:51 +02:00
Raphael Michel
53fe4a32cd PyPI CI: Add check-manifest and twine check 2020-07-27 16:25:23 +02:00
Raphael Michel
ff066898d4 Fix RTL issue 2020-07-27 16:25:23 +02:00
Felix Rindt
cbb848b3fa style 2020-07-24 18:47:59 +02:00
Felix Rindt
98dfdd8b01 no ugettext 2020-07-24 18:44:35 +02:00
Felix Rindt
0e95a7863f tests for placed and paid mails 2020-07-24 18:44:24 +02:00
Raphael Michel
0913f5bc18 Merge pull request #1729 from pretix/project-setup-things 2020-07-24 18:12:34 +02:00
Raphael Michel
d1eb4c4cce Add documentation on additional indices 2020-07-24 18:10:25 +02:00
Felix Rindt
4a0a3aff59 rename to download_reminder 2020-07-24 17:57:25 +02:00
Raphael Michel
83908fde45 [experimental] restructure order search query for different performance characteristics 2020-07-24 17:48:50 +02:00
Felix Rindt
143ac10991 rebase migration 2020-07-24 16:59:24 +02:00
Felix Rindt
413cbec4b9 code format 2020-07-24 16:58:05 +02:00
Felix Rindt
b168516d78 user guide 2020-07-24 16:58:05 +02:00
Felix Rindt
d0ccc42aff add test for ticket reminder (oops) 2020-07-24 16:58:05 +02:00
Felix Rindt
7aa793f4f7 fix name 2020-07-24 16:58:05 +02:00
Felix Rindt
1b48b519e3 add migration 2020-07-24 16:58:05 +02:00
Felix Rindt
5f502776b1 send canonical mails depending on sales channel 2020-07-24 16:58:05 +02:00
Felix Rindt
985e1ac9bf Fix TypeError: Unknown option(s) for shell command: skip_checks. 2020-07-24 16:53:51 +02:00
Felix Rindt
df1014d62f modernize isort config for v5.0 2020-07-24 15:55:24 +02:00
Felix Rindt
062afc42d3 change _decimal to decimal 2020-07-24 15:55:05 +02:00
Raphael Michel
1fb861a117 New attempt at improving CheckinList.checkin_count 2020-07-24 15:41:41 +02:00
Raphael Michel
0a2346778d Revert "Refactor query for check-in count"
This reverts commit 60eee25cd1.
2020-07-24 15:37:45 +02:00
Raphael Michel
605a21a0cf Typeahead: Remove ordering of orders to improve query performance 2020-07-24 15:29:26 +02:00
Raphael Michel
1cfec9cc99 Revert "Typeahead: No substring match in admin sessions"
This reverts commit 2626259492.
2020-07-24 15:28:14 +02:00
Raphael Michel
0a97b0ce67 Add progress bar for checkin list export 2020-07-24 13:54:38 +02:00
Raphael Michel
60eee25cd1 Refactor query for check-in count 2020-07-24 13:54:30 +02:00
Raphael Michel
779ec6c3f6 Metrics: Return accurate counts for less interesting models 2020-07-24 13:53:59 +02:00
Raphael Michel
988eb85c05 Fix exporter issue 2020-07-24 11:40:45 +02:00
Raphael Michel
556cb7c46d Add INV to wordlist 2020-07-24 11:01:29 +02:00
Raphael Michel
86e3c30633 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-07-24 10:44:45 +02:00
Raphael Michel
6276f213b9 Increae field size for CachedFile.file 2020-07-24 10:44:08 +02:00
Raphael Michel
524f6c9975 Merge pull request #1727 from pretix-translations/weblate-pretix-pretix 2020-07-24 10:43:45 +02:00
Andreas Teuber
125a14c8e9 Translated on translate.pretix.eu (Chinese (Simplified))
Currently translated at 82.9% (3080 of 3714 strings)

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

powered by weblate
2020-07-24 09:35:20 +02:00
Yaling
c7f0e6f652 Translated on translate.pretix.eu (Chinese (Simplified))
Currently translated at 82.9% (3080 of 3714 strings)

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

powered by weblate
2020-07-24 09:35:20 +02:00
Andreas Teuber
1e58ef6f9e Translated on translate.pretix.eu (Chinese (Simplified))
Currently translated at 82.2% (3052 of 3714 strings)

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

powered by weblate
2020-07-24 09:35:20 +02:00
Raphael Michel
41127ce978 Fix issue in OrderListExporter when mixing subevents with non-subevents 2020-07-24 09:35:11 +02:00
Raphael Michel
99b42d201e Add missing tqdm binary 2020-07-24 09:08:22 +02:00
Raphael Michel
265da6c746 Progress bar instead of acks_late for event cancellation 2020-07-23 21:54:03 +02:00
Raphael Michel
d58c8559fc Long-running async tasks: Expose running state 2020-07-23 21:39:13 +02:00
Raphael Michel
b5dca762f0 Cancelling events: Fix send_waitinglist flag 2020-07-23 21:38:58 +02:00
Raphael Michel
a310c33497 Add progress bar to some large exports 2020-07-23 21:35:58 +02:00
Raphael Michel
fc5c3caf66 Fix memory usage in exporters by using chunked iterators 2020-07-23 20:39:49 +02:00
Raphael Michel
bff1041878 Excel export: Use openpyxl's constant memory implementation 2020-07-23 20:37:15 +02:00
Raphael Michel
2626259492 Typeahead: No substring match in admin sessions 2020-07-23 18:20:41 +02:00
Martin Gross
18415c62bb Cancellations now use up to date invoice issuer information and do not copy the information over from the original invoice. 2020-07-23 18:02:18 +02:00
Raphael Michel
85f546a3a6 Ignore deadlock when writing quota caches 2020-07-23 17:48:56 +02:00
Raphael Michel
829b0041fc Use database replica for check-in count for statistical purposes 2020-07-23 17:48:31 +02:00
Raphael Michel
4968a6d995 Do not count exists for checkin count 2020-07-23 17:48:18 +02:00
Raphael Michel
033deb7cf2 Add seat information to check-in list export 2020-07-23 12:26:54 +02:00
Felix Rindt
e23e88f5c3 Create invoice exporter mixin (#1725)
* Create invoice exporter mixin

* code style
2020-07-22 17:22:56 +02:00
Raphael Michel
c3745e792b Fix PaymentProviderForm issue 2020-07-22 16:09:57 +02:00
Raphael Michel
735d4564f8 Allow to change length of invoice numbers 2020-07-21 18:11:39 +02:00
Raphael Michel
b305ac012c Fix price field when increasing number of bundles in cart 2020-07-21 17:23:30 +02:00
Raphael Michel
7bd9a01f5e Fix error in price calculation in connection with free prices and bundles 2020-07-21 17:23:08 +02:00
Raphael Michel
8bebea61f1 Improve performance of quota cache task 2020-07-21 16:58:18 +02:00
Raphael Michel
6714ab24ee Force-upgrade hierarkey 2020-07-21 16:58:12 +02:00
Raphael Michel
a54dbc0110 Allow file upload in payment provider settings 2020-07-21 11:52:46 +02:00
Raphael Michel
19fa2fb016 CSP: Remove child-src, as it is redundant with frame-src and will get deprecated again 2020-07-21 10:59:13 +02:00
Raphael Michel
12b5d6663e Adjust widget tests 2020-07-21 10:09:51 +02:00
Raphael Michel
ca4db5f628 Widget: respect item.allow_waitinglist 2020-07-21 09:46:30 +02:00
Raphael Michel
b6a343a623 Add umzubuchen to German spelling list 2020-07-20 17:28:19 +02:00
Raphael Michel
dc451cdeea Merge pull request #1722 from pretix-translations/weblate-pretix-pretix 2020-07-20 17:24:51 +02:00
Raphael Michel
6732d13439 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3714 of 3714 strings)

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

powered by weblate
2020-07-20 17:24:32 +02:00
Raphael Michel
5bf67ba613 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3714 of 3714 strings)

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

powered by weblate
2020-07-20 17:24:31 +02:00
TimPrd
8885b50972 Translated on translate.pretix.eu (French)
Currently translated at 62.3% (2306 of 3699 strings)

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

powered by weblate
2020-07-20 16:43:06 +02:00
Raphael Michel
940566ab93 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-07-20 16:42:55 +02:00
Raphael Michel
e7b9c49620 Allow customers to change to a different product variation (#1719) 2020-07-20 16:36:24 +02:00
Raphael Michel
c8ef825de5 Try again to fix export tests 2020-07-20 14:56:03 +02:00
Raphael Michel
5b25a68599 Fix tests broken in 684212780 2020-07-20 14:27:37 +02:00
Raphael Michel
e26a07d44d Add documentation on URL interpolation in digital content module 2020-07-20 11:43:05 +02:00
Martin Gross
6842127802 Absent/Checked Out persons in Checkin lists (#1721) 2020-07-20 10:41:39 +02:00
Raphael Michel
3c5948d2e0 Allow selecting the same add-on multiple times (#1717) 2020-07-20 10:21:12 +02:00
Raphael Michel
ed3542e219 Fix error in quota statistics 2020-07-20 10:10:36 +02:00
Raphael Michel
e439b20618 Fix crash if gift card does not exist 2020-07-17 17:44:01 +02:00
Raphael Michel
5c1fe6f68c Bump to 3.11.0.dev0 2020-07-17 12:56:57 +02:00
233 changed files with 55131 additions and 46294 deletions

View File

@@ -31,7 +31,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system packages
run: sudo apt install enchant hunspell aspell-en
run: sudo apt update && sudo apt install enchant hunspell aspell-en
- name: Install Dependencies
run: pip3 install --no-use-pep517 -Ur doc/requirements.txt
- name: Spellcheck docs

View File

@@ -29,7 +29,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system packages
run: sudo apt install gettext
run: sudo apt update && sudo apt install gettext
- name: Install Dependencies
run: pip3 install --no-use-pep517 -Ur src/requirements.txt
- name: Compile messages
@@ -54,7 +54,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system packages
run: sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
run: sudo apt update && sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
- name: Install Dependencies
run: pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
- name: Spellcheck translations

View File

@@ -31,7 +31,7 @@ jobs:
- name: Install Dependencies
run: pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
- name: Run isort
run: isort -c -rc -df .
run: isort -c .
working-directory: ./src
flake:
name: flake8

View File

@@ -55,7 +55,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt install gettext mysql-client
run: sudo apt update && sudo apt install gettext mysql-client
- name: Install Python dependencies
run: pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt mysqlclient psycopg2-binary
- name: Run checks

View File

@@ -20,15 +20,17 @@ pypi:
- cp /keys/.pypirc ~/.pypirc
- virtualenv env
- source env/bin/activate
- pip install -U pip wheel setuptools
- pip install -U pip wheel setuptools check-manifest twine
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
- cd src
- python setup.py sdist
- pip install dist/pretix-*.tar.gz
- python -m pretix migrate
- python -m pretix check
- python setup.py sdist upload
- python setup.py bdist_wheel upload
- check-manifest
- python setup.py sdist bdist_wheel
- twine check dist/*
- twine upload dist/*
tags:
- python3
only:

View File

@@ -29,7 +29,7 @@ RUN apt-get update && \
mkdir /etc/pretix && \
mkdir /data && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
echo 'pretixuser ALL=(ALL) NOPASSWD: /usr/bin/supervisord' >> /etc/sudoers && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static
ENV LC_ALL=C.UTF-8 \

View File

@@ -19,9 +19,8 @@ Reinventing ticket presales, one ticket at a time.
Project status & release cycle
------------------------------
While there is always a lot to do and improve on, pretix by now has been in use for more than a dozen
conferences that sold over ten thousand tickets combined without major problems. We therefore think of
pretix as being stable and ready to use.
While there is always a lot to do and improve on, pretix by now has been in use for thousands of events
conferences that sold millions of tickets combined. We therefore think of pretix as being stable and ready to use.
If you want to use or extend pretix, we strongly recommend to follow our `blog`_. We will announce all
releases there. You can always find the latest stable version on PyPI or in the ``release/X.Y`` branch of
@@ -30,9 +29,13 @@ the sense that it does not break your data, but its APIs might change without p
To get started using pretix on your own server, look at the `installation guide`_ in our documentation.
This project is 100 percent free and open source software. If you are interested in commercial support,
hosting services or supporting this project financially, please go to `pretix.eu`_ or contact us at
support@pretix.eu.
Support
-------
This project is 100 percent free and open source software. You are welcome to ask questions in the GitHub
repository. Private support via email or phone is only offered to customers of our pretix Hosted or pretix
Enterprise offerings. If you are interested in commercial support, hosting services or supporting this project
financially, please go to `pretix.eu`_ or contact us at support@pretix.eu.
Contributing
------------
@@ -52,8 +55,8 @@ License
The code in this repository is published under the terms of the Apache License.
See the LICENSE file for the complete license text.
This project is maintained by Raphael Michel <mail@raphaelmichel.de>. See the
AUTHORS file for a list of all the awesome folks who contributed to this project.
This project is maintained by Raphael Michel. See the AUTHORS file for a list of all
the awesome folks who contributed to this project.
.. _installation guide: https://docs.pretix.eu/en/latest/admin/installation/index.html
.. _developer documentation: https://docs.pretix.eu/en/latest/development/index.html

View File

@@ -19,7 +19,7 @@ fi
python3 -m pretix migrate --noinput
if [ "$1" == "all" ]; then
exec sudo /usr/bin/supervisord -n -c /etc/supervisord.conf
exec sudo -E /usr/bin/supervisord -n -c /etc/supervisord.conf
fi
if [ "$1" == "webworker" ]; then

View File

@@ -339,6 +339,15 @@ application. If you want to use sentry, you need to set a DSN in the configurati
You will be given this value by your sentry installation.
Caching
-------
You can adjust some caching settings to control how much storage pretix uses::
[cache]
tickets=48 ; Number of hours tickets (PDF, passbook, …) are cached
Secret length
-------------

View File

@@ -12,3 +12,4 @@ This documentation is for everyone who wants to install pretix on a server.
config
maintainance
scaling
indexes

73
doc/admin/indexes.rst Normal file
View File

@@ -0,0 +1,73 @@
Additional database indices
===========================
If you have a large pretix database, some features such as search for orders or events might turn pretty slow.
For PostgreSQL, we have compiled a list of additional database indexes that you can add to speed things up.
Just like any index, they in turn make write operations insignificantly slower and cause the database to use
more disk space.
The indexes aren't automatically created by pretix since Django does not allow us to do so only on PostgreSQL
(and they won't work on other databases). Also, they're really not necessary if you're not having tens of
thousands of records in your database.
However, this also means they won't automatically adapt if some of the referred fields change in future updates of pretix
and you might need to re-check this page and change them manually.
Here is the currently recommended set of commands::
CREATE EXTENSION pg_trgm;
CREATE INDEX CONCURRENTLY pretix_addidx_event_slug
ON pretixbase_event
USING gin (upper("slug") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_event_name
ON pretixbase_event
USING gin (upper("name") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_order_code
ON pretixbase_order
USING gin (upper("code") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_voucher_code
ON pretixbase_voucher
USING gin (upper("code") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_invoice_nu1
ON "pretixbase_invoice" (UPPER("invoice_no"));
CREATE INDEX CONCURRENTLY pretix_addidx_invoice_nu2
ON "pretixbase_invoice" (UPPER("full_invoice_no"));
CREATE INDEX CONCURRENTLY pretix_addidx_organizer_name
ON pretixbase_organizer
USING gin (upper("name") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_organizer_slug
ON pretixbase_organizer
USING gin (upper("slug") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_order_email
ON pretixbase_order
USING gin (upper("email") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_order_comment
ON pretixbase_order
USING gin (upper("comment") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_name
ON pretixbase_orderposition
USING gin (upper("attendee_name_cached") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_scret
ON pretixbase_orderposition
USING gin (upper("secret") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_orderpos_email
ON pretixbase_orderposition
USING gin (upper("attendee_email") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_ia_name
ON pretixbase_invoiceaddress
USING gin (upper("name_cached") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_ia_company
ON pretixbase_invoiceaddress
USING gin (upper("company") gin_trgm_ops);
Also, if you use our ``pretix-shipping`` plugin::
CREATE INDEX CONCURRENTLY pretix_addidx_sa_name
ON pretix_shipping_shippingaddress
USING gin (upper("name") gin_trgm_ops);
CREATE INDEX CONCURRENTLY pretix_addidx_sa_company
ON pretix_shipping_shippingaddress
USING gin (upper("company") gin_trgm_ops);

View File

@@ -26,7 +26,7 @@ installation guides):
* `Docker`_
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
* A `PostgreSQL`_ 9.5+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
* A `redis`_ server
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
@@ -290,7 +290,7 @@ to re-build your custom image after you pulled ``pretix/standalone`` if you want
.. _Let's Encrypt: https://letsencrypt.org/
.. _pretix.eu: https://pretix.eu/
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-9-4-on-debian-8
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
.. _redis website: https://redis.io/topics/security

View File

@@ -23,7 +23,7 @@ installation guides):
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
* A `PostgreSQL`_ 9.5+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
* A `redis`_ server
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
@@ -308,7 +308,7 @@ example::
.. _Let's Encrypt: https://letsencrypt.org/
.. _pretix.eu: https://pretix.eu/
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-9-4-on-debian-8
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
.. _strong encryption settings: https://mozilla.github.io/server-side-tls/ssl-config-generator/

View File

@@ -92,7 +92,8 @@ pretix_task_duration_seconds
pretix_model_instances
Gauge. Measures number of instances of a certain model within the database, labeled with
the ``model`` name.
the ``model`` name. Starting with pretix 3.11, these numbers might only be approximate for
most tables when running on PostgreSQL to mitigate performance impact.
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
.. _Prometheus: https://prometheus.io/

View File

@@ -7,9 +7,6 @@ This part of the documentation contains information about the REST-style API
exposed by pretix since version 1.5 that can be used by third-party programs
to interact with pretix and its data structures.
Currently, the API provides mostly read-only capabilities, but it will be extended
in functionality over time.
.. toctree::
:maxdepth: 2

View File

@@ -56,6 +56,10 @@ rules object Custom check-in
The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``,
``allow_entry_after_exit``, and ``rules`` attributes have been added.
.. versionchanged:: 3.11
The ``subevent_match`` and ``exclude`` query parameters have been added.
Endpoints
---------
@@ -109,6 +113,8 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1
:query integer subevent: Only return check-in lists of the sub-event with the given ID
:query integer subevent_match: Only return check-in lists that are valid for the sub-event with the given ID (i.e. also lists valid for all subevents)
: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.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error

View File

@@ -24,6 +24,7 @@ addon_category integer Internal ID of
min_count integer The minimal number of add-ons that need to be chosen.
max_count integer The maximal number of add-ons that can be chosen.
position integer An integer, used for sorting
multi_allowed boolean Adding the same item multiple times is allowed
price_included boolean Adding this add-on to the item is free
===================================== ========================== =======================================================
@@ -65,6 +66,7 @@ Endpoints
"min_count": 0,
"max_count": 10,
"position": 0,
"multi_allowed": false,
"price_included": true
},
{
@@ -73,6 +75,7 @@ Endpoints
"min_count": 0,
"max_count": 10,
"position": 1,
"multi_allowed": false,
"price_included": true
}
]
@@ -112,6 +115,7 @@ Endpoints
"min_count": 0,
"max_count": 10,
"position": 1,
"multi_allowed": false,
"price_included": true
}
@@ -141,6 +145,7 @@ Endpoints
"min_count": 0,
"max_count": 10,
"position": 1,
"multi_allowed": false,
"price_included": true
}
@@ -158,6 +163,7 @@ Endpoints
"min_count": 0,
"max_count": 10,
"position": 1,
"multi_allowed": false,
"price_included": true
}
@@ -206,6 +212,7 @@ Endpoints
"min_count": 0,
"max_count": 10,
"position": 1,
"multi_allowed": false,
"price_included": true
}

View File

@@ -104,6 +104,7 @@ addons list of objects Definition of a
├ min_count integer The minimal number of add-ons that need to be chosen.
├ max_count integer The maximal number of add-ons that can be chosen.
├ position integer An integer, used for sorting
├ multi_allowed boolean Adding the same item multiple times is allowed
└ price_included boolean Adding this add-on to the item is free
bundles list of objects Definition of bundles that are included in this item.
Only writable during creation,
@@ -159,6 +160,10 @@ meta_data object Values set for
The attribute ``meta_data`` has been added.
.. versionchanged:: 3.10
The attribute ``multi_allowed`` has been added to ``addons``.
Notes
-----

View File

@@ -159,6 +159,10 @@ last_modified datetime Last modificati
The ``search`` query parameter has been added.
.. versionchanged:: 3.11
The ``exclude`` and ``subevent_after`` query parameter has been added.
.. _order-position-resource:
@@ -485,6 +489,8 @@ List of all orders
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
you will not notice it using this method.
:query datetime created_since: Only return orders that have been created since the given date.
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date.
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
@@ -928,7 +934,8 @@ Creating orders
during order generation and is not respected automatically when the order changes later.)
* ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order. Defaults to
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
whether these emails are enabled for certain sales channels. Defaults to
``false``.
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually

View File

@@ -33,7 +33,7 @@ Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
.. automodule:: pretix.presale.signals
@@ -66,19 +66,13 @@ Vouchers
""""""""
.. automodule:: pretix.control.signals
:members: item_forms
Vouchers
""""""""
.. automodule:: pretix.control.signals
:members: voucher_form_class, voucher_form_html, voucher_form_validation
:members: item_forms, voucher_form_class, voucher_form_html, voucher_form_validation
Dashboards
""""""""""
.. automodule:: pretix.control.signals
:members: event_dashboard_widgets, user_dashboard_widgets
:members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top
Ticket designs
""""""""""""""

View File

@@ -126,6 +126,8 @@ The provider class
.. autoattribute:: test_mode_message
.. autoattribute:: requires_invoice_immediately
Additional views
----------------

View File

@@ -7,7 +7,7 @@ Coding style and quality
for more information. Use four spaces for indentation.
* We sort our imports by a certain schema, but you don't have to do this by hand. Again, ``setup.cfg`` contains
some definitions that allow the command ``isort -rc <directory>`` to automatically sort the imports in your source
some definitions that allow the command ``isort <directory>`` to automatically sort the imports in your source
files.
* For templates and models, please take a look at the `Django Coding Style`_. We like Django's `class-based views`_ and

View File

@@ -98,7 +98,7 @@ pull request nevertheless and ask us for help, we are happy to assist you.
Execute the following commands to check for code style errors::
flake8 .
isort -c -rc .
isort -c .
python manage.py check
Execute the following command to run pretix' test suite (might take a couple of minutes)::
@@ -121,7 +121,7 @@ for example, to check for any errors in any staged files when committing::
do
echo $file
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
git show ":$file" | isort -df --check-only - | grep ERROR && exit 1 || true
git show ":$file" | isort -c - | grep ERROR && exit 1 || true
done

View File

@@ -1,12 +1,86 @@
Digital content
===============
URL interpolation and JWT authentication
----------------------------------------
In the simplest case, you can use the digital content module to point users to a specific piece of content on some
platform after their ticket purchase, or show them an embedded video or live stream. However, the full power of the
module can be utilized by passing additional information to the target system to automatically authenticate the user
or pre-fill some fields with their data. For example, you could use an URL like this::
https://webinars.example.com/join?as={attendee_name}&userid={order_code}-{positionid}
While this is already useful, it does not provide much security anyone could guess a valid combination for that URL.
Therefore, the module allows you to pass information as a `JSON Web Token`_, which isn't encrypted, but signed with a
shared secret such that nobody can create their own tokens or modify the contents. To use a token, set up a URL like this::
https://webinars.example.com/join?with_token={token}
Additionally, you will need to set a JWT secret and a token template, either through the pretix interface or through the
API (see below). pretix currently only supports tokens signed with ``HMAC-SHA256`` (``HS256``). Your token template can contain
whatever JSON you'd like to pass on based on the same variables, for example::
{
"iss": "pretix.eu",
"aud": "webinars.example.com",
"user": {
"id": "{order_code}-{positionid}",
"product": "{product_id}",
"variation": "{variation_id}",
"name": "{attendee_name}"
}
}
Variables can only be used in strings inside the JSON structure.
pretix will automatically add an ``iat`` claim with the current timestamp and an ``exp`` claim with an expiration timestamp
based on your configuration.
List of variables
"""""""""""""""""
The following variables are currently supported:
.. rst-class:: rest-resource-table
=================================== ====================================================================
Variable Description
=================================== ====================================================================
``order_code`` Order code (alphanumerical, unique per order, not per ticket)
``positionid`` ID of the ticket within the order (integer, starting at 1)
``order_email`` E-mail address of the ticket purchaser
``product_id`` Internal ID of the purchased product
``product_variation`` Internal ID of the purchased product variation (or empty)
``attendee_name`` Full name of the ticket holder (or empty)
``attendee_name_*`` Name parts of the ticket holder, depending on configuration, e.g. ``attendee_name_given_name`` or ``attendee_name_family_name``
``attendee_email`` E-mail address of the ticket holder (or empty)
``attendee_company`` Company of the ticket holder (or empty)
``attendee_street`` Street of the ticket holder's address (or empty)
``attendee_zipcode`` ZIP code of the ticket holder's address (or empty)
``attendee_city`` City of the ticket holder's address (or empty)
``attendee_country`` Country code of the ticket holder's address (or empty)
``attendee_state`` State of the ticket holder's address (or empty)
``answer[XYZ]`` Answer to the custom question with identifier ``XYZ``
``invoice_name`` Full name of the invoice address (or empty)
``invoice_name_*`` Name parts of the invoice address, depending on configuration, e.g. ``invoice_name_given_name`` or ``invoice_name_family_name``
``invoice_company`` Company of the invoice address (or empty)
``invoice_street`` Street of the invoice address (or empty)
``invoice_zipcode`` ZIP code of the invoice address (or empty)
``invoice_city`` City of the invoice address (or empty)
``invoice_country`` Country code of the invoice address (or empty)
``invoice_state`` State of the invoice address (or empty)
``meta_XYZ`` Value of the event's ``XYZ`` meta property
``token`` Signed JWT (only to be used in URLs, not in tokens)
=================================== ====================================================================
API Resource description
-------------------------
The digital content plugin provides a HTTP API that allows you to create new digital content for your ticket holders,
such as live streams, videos, or material downloads.
Resource description
--------------------
The digital content resource contains the following public fields:
.. rst-class:: rest-resource-table
@@ -28,10 +102,13 @@ all_products boolean If ``true``, th
limit_products list of integers List of product/item IDs. This content is only shown to buyers of these ticket types.
position integer An integer, used for sorting
subevent integer Date in an event series this content should be shown for. Should be ``null`` if this is not an event series or if this should be shown to all customers.
jwt_template string Template for JWT token generation
jwt_secret string Secret for JWT token generation
jwt_validity integer JWT validity in days
===================================== ========================== =======================================================
Endpoints
---------
API Endpoints
-------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/
@@ -275,3 +352,5 @@ Endpoints
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/content does not exist **or** you have no permission to change it
.. _JSON Web Token: https://en.wikipedia.org/wiki/JSON_Web_Token

View File

@@ -47,6 +47,7 @@ gunicorn
guid
hardcoded
hostname
ics
idempotency
iframe
incrementing
@@ -54,6 +55,8 @@ inofficial
invalidations
iterable
Jimdo
jwt
JWT
libpretixprint
libsass
linters

View File

@@ -26,6 +26,9 @@ Sender address
we strongly recommend to use the SMTP settings below as well, otherwise your e-mails might be detected as spam
due to the `Sender Policy Framework`_ and similar mechanisms.
Sender name
This is the name associated with the sender address. By default, this is your event name.
Signature
This text will be appended to all e-mails in form of a signature. This might be useful e.g. to add your contact
details or any legal information that needs to be included with the e-mails.
@@ -33,6 +36,15 @@ Signature
Bcc address
This email address will receive a copy of every event-related email.
Attach calendar files
With this option, every order confirmation mail will include an ics file with name, date and location of
your event. It can be imported into many digital calendars.
Sales Channels for Checkout Emails
When you are using multiple sales channel, you may want to decide that mails for order and payment confirmation
are only to be sent for some sales channels. For orders created through the default online shop, these emails
must always be send. A similar option is available for ticket download reminders.
E-mail design
-------------

View File

@@ -1 +1 @@
__version__ = "3.10.0"
__version__ = "3.11.0.dev0"

View File

@@ -17,6 +17,17 @@ class CheckinListSerializer(I18nAwareModelSerializer):
'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
'rules')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for exclude_field in self.context['request'].query_params.getlist('exclude'):
p = exclude_field.split('.')
if p[0] in self.fields:
if len(p) == 1:
del self.fields[p[0]]
elif len(p) == 2:
self.fields[p[0]].child.fields.pop(p[1])
def validate(self, data):
data = super().validate(data)
event = self.context['event']

View File

@@ -29,6 +29,9 @@ class MetaDataField(Field):
}
def to_internal_value(self, data):
if not isinstance(data, dict) or not all(isinstance(k, str) for k in data.keys()):
raise ValidationError('meta_data needs to be an object (str -> str).')
return {
'meta_data': data
}
@@ -42,6 +45,8 @@ class MetaPropertyField(Field):
}
def to_internal_value(self, data):
if not isinstance(data, dict) or not all(isinstance(k, str) for k in data.keys()) or not all(isinstance(k, str) for k in data.values()):
raise ValidationError('item_meta_properties needs to be an object (str -> str).')
return {
'item_meta_properties': data
}
@@ -58,6 +63,8 @@ class SeatCategoryMappingField(Field):
}
def to_internal_value(self, data):
if not isinstance(data, dict) or not all(isinstance(k, str) for k in data.keys()) or not all(isinstance(k, int) for k in data.values()):
raise ValidationError('seat_category_mapping needs to be an object (str -> int).')
return {
'seat_category_mapping': data or {}
}
@@ -452,27 +459,29 @@ class SubEventSerializer(I18nAwareModelSerializer):
@transaction.atomic
def update(self, instance, validated_data):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
item_price_overrides_data = validated_data.pop('subeventitem_set', None)
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set', None)
meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().update(instance, validated_data)
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
if item_price_overrides_data is not None:
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
for item_price_override_data in item_price_overrides_data:
id = existing_item_overrides.pop(item_price_override_data['item'], None)
SubEventItem(id=id, subevent=subevent, **item_price_override_data).save()
for item_price_override_data in item_price_overrides_data:
id = existing_item_overrides.pop(item_price_override_data['item'], None)
SubEventItem(id=id, subevent=subevent, **item_price_override_data).save()
SubEventItem.objects.filter(id__in=existing_item_overrides.values()).delete()
SubEventItem.objects.filter(id__in=existing_item_overrides.values()).delete()
existing_variation_overrides = {item.variation: item.id for item in SubEventItemVariation.objects.filter(subevent=subevent)}
if variation_price_overrides_data is not None:
existing_variation_overrides = {item.variation: item.id for item in SubEventItemVariation.objects.filter(subevent=subevent)}
for variation_price_override_data in variation_price_overrides_data:
id = existing_variation_overrides.pop(variation_price_override_data['variation'], None)
SubEventItemVariation(id=id, subevent=subevent, **variation_price_override_data).save()
for variation_price_override_data in variation_price_overrides_data:
id = existing_variation_overrides.pop(variation_price_override_data['variation'], None)
SubEventItemVariation(id=id, subevent=subevent, **variation_price_override_data).save()
SubEventItemVariation.objects.filter(id__in=existing_variation_overrides.values()).delete()
SubEventItemVariation.objects.filter(id__in=existing_variation_overrides.values()).delete()
# Meta data
if meta_data is not None:
@@ -565,7 +574,7 @@ class EventSettingsSerializer(serializers.Serializer):
'attendee_addresses_required',
'attendee_company_asked',
'attendee_company_required',
'confirm_text',
'confirm_texts',
'order_email_asked_twice',
'payment_term_days',
'payment_term_last',
@@ -597,6 +606,7 @@ class EventSettingsSerializer(serializers.Serializer):
'invoice_numbers_consecutive',
'invoice_numbers_prefix',
'invoice_numbers_prefix_cancellations',
'invoice_numbers_counter_length',
'invoice_attendee_name',
'invoice_include_expire_date',
'invoice_address_explanation_text',
@@ -623,6 +633,9 @@ class EventSettingsSerializer(serializers.Serializer):
'cancel_allow_user_paid_adjust_fees_explanation',
'cancel_allow_user_paid_refund_as_giftcard',
'cancel_allow_user_paid_require_approval',
'change_allow_user_variation',
'change_allow_user_until',
'change_allow_user_price',
]
def __init__(self, *args, **kwargs):

View File

@@ -45,7 +45,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
class Meta:
model = ItemAddOn
fields = ('addon_category', 'min_count', 'max_count',
'position', 'price_included')
'position', 'price_included', 'multi_allowed')
class ItemBundleSerializer(serializers.ModelSerializer):
@@ -77,7 +77,7 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
class Meta:
model = ItemAddOn
fields = ('id', 'addon_category', 'min_count', 'max_count',
'position', 'price_included')
'position', 'price_included', 'multi_allowed')
def validate(self, data):
data = super().validate(data)

View File

@@ -270,8 +270,9 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'require_attention',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
'order__status')
@@ -376,6 +377,14 @@ class OrderSerializer(I18nAwareModelSerializer):
if not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields['positions'].child.fields.pop('pdf_data')
for exclude_field in self.context['request'].query_params.getlist('exclude'):
p = exclude_field.split('.')
if p[0] in self.fields:
if len(p) == 1:
del self.fields[p[0]]
elif len(p) == 2:
self.fields[p[0]].child.fields.pop(p[1])
def validate_locale(self, l):
if l not in set(k for k in self.instance.event.settings.locales):
raise ValidationError('"{}" is not a supported locale for this event.'.format(l))

View File

@@ -6,6 +6,7 @@ from django_scopes import scopes_disabled
from pretix.api.models import ApiCall, WebHookCall
from pretix.base.signals import periodic_task
from pretix.helpers.periodic import minimum_interval
register_webhook_events = Signal(
providing_args=[]
@@ -19,11 +20,13 @@ instances.
@receiver(periodic_task)
@scopes_disabled()
@minimum_interval(minutes_after_success=12 * 60)
def cleanup_webhook_logs(sender, **kwargs):
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
@receiver(periodic_task)
@scopes_disabled()
@minimum_interval(minutes_after_success=12 * 60)
def cleanup_api_logs(sender, **kwargs):
ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete()

View File

@@ -1,3 +1,4 @@
import django_filters
from django.core.exceptions import ValidationError
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
from django.db.models.functions import Coalesce
@@ -27,10 +28,17 @@ from pretix.helpers.database import FixedOrderBy
with scopes_disabled():
class CheckinListFilter(FilterSet):
subevent_match = django_filters.NumberFilter(method='subevent_match_qs')
class Meta:
model = CheckinList
fields = ['subevent']
def subevent_match_qs(self, qs, name, value):
return qs.filter(
Q(subevent_id=value) | Q(subevent_id__isnull=True)
)
class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer
@@ -192,7 +200,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
except ValueError:
raise Http404()
def get_queryset(self, ignore_status=False):
def get_queryset(self, ignore_status=False, ignore_products=False):
cqs = Checkin.objects.filter(
position_id=OuterRef('pk'),
list_id=self.checkinlist.pk
@@ -247,12 +255,12 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
if not self.checkinlist.all_products:
if not self.checkinlist.all_products and not ignore_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
return qs
@action(detail=True, methods=['POST'])
@action(detail=False, methods=['POST'], url_name='redeem', url_path='(?P<pk>[^/]+)/redeem')
def redeem(self, *args, **kwargs):
force = bool(self.request.data.get('force', False))
type = self.request.data.get('type', None) or Checkin.TYPE_ENTRY
@@ -260,13 +268,27 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
raise ValidationError("Invalid check-in type.")
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce')
op = self.get_object(ignore_status=True)
if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
else:
dt = now()
try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
if self.kwargs['pk'].isnumeric():
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:
op = queryset.get(secret=self.kwargs['pk'])
except OrderPosition.DoesNotExist:
self.request.event.log_action('pretix.event.checkin.unknown', data={
'datetime': dt,
'type': type,
'list': self.checkinlist.pk,
'barcode': self.kwargs['pk']
}, user=self.request.user, auth=self.request.auth)
raise Http404()
given_answers = {}
if 'answers' in self.request.data:
aws = self.request.data.get('answers')
@@ -302,6 +324,14 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
]
}, status=400)
except CheckInError as e:
op.order.log_action('pretix.event.checkin.denied', data={
'position': op.id,
'positionid': op.positionid,
'errorcode': e.code,
'datetime': dt,
'type': type,
'list': self.checkinlist.pk
}, user=self.request.user, auth=self.request.auth)
return Response({
'status': 'error',
'reason': e.code,
@@ -314,11 +344,3 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
}, status=201)
def get_object(self, ignore_status=False):
queryset = self.filter_queryset(self.get_queryset(ignore_status=ignore_status))
if self.kwargs['pk'].isnumeric():
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:
obj = get_object_or_404(queryset, secret=self.kwargs['pk'])
return obj

View File

@@ -31,7 +31,7 @@ from pretix.api.serializers.order import (
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota,
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
TeamAPIToken, generate_position_secret, generate_secret,
)
from pretix.base.payment import PaymentException
@@ -61,12 +61,26 @@ with scopes_disabled():
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
subevent_after = django_filters.IsoDateTimeFilter(method='subevent_after_qs')
search = django_filters.CharFilter(method='search_qs')
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
def subevent_after_qs(self, qs, name, value):
qs = qs.annotate(
has_se_after=Exists(
OrderPosition.all.filter(
subevent_id__in=SubEvent.objects.filter(
Q(date_to__gt=value) | Q(date_from__gt=value, date_to__isnull=True), event=OuterRef(OuterRef('event_id'))
).values_list('id'),
order_id=OuterRef('pk'),
)
)
).filter(has_se_after=True)
return qs
def search_qs(self, qs, name, value):
u = value
if "-" in value:
@@ -121,16 +135,19 @@ class OrderViewSet(viewsets.ModelViewSet):
return ctx
def get_queryset(self):
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
fqs = OrderFee.all
else:
fqs = OrderFee.objects
qs = self.request.event.orders.prefetch_related(
Prefetch('fees', queryset=fqs.all()),
'payments', 'refunds', 'refunds__payment'
).select_related(
'invoice_address'
)
qs = self.request.event.orders
if 'fees' not in self.request.GET.getlist('exclude'):
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
fqs = OrderFee.all
else:
fqs = OrderFee.objects
qs = qs.prefetch_related(Prefetch('fees', queryset=fqs.all()))
if 'payments' not in self.request.GET.getlist('exclude'):
qs = qs.prefetch_related('payments')
if 'refunds' not in self.request.GET.getlist('exclude'):
qs = qs.prefetch_related('refunds', 'refunds__payment')
if 'invoice_address' not in self.request.GET.getlist('exclude'):
qs = qs.select_related('invoice_address')
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
opq = OrderPosition.all
@@ -167,6 +184,7 @@ class OrderViewSet(viewsets.ModelViewSet):
return prov
raise NotFound('Unknown output provider.')
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs):
date = serializers.DateTimeField().to_representation(now())
queryset = self.filter_queryset(self.get_queryset())

View File

@@ -258,9 +258,30 @@ def _placeholder_payment(order, payment):
return str(payment.payment_provider.order_pending_mail_render(order))
def get_best_name(position_or_address, parts=False):
"""
Return the best name we got for either an invoice address or an order position, falling back to the respective other
"""
from pretix.base.models import InvoiceAddress, OrderPosition
if isinstance(position_or_address, InvoiceAddress):
if position_or_address.name:
return position_or_address.name_parts if parts else position_or_address.name
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
if isinstance(position_or_address, OrderPosition):
if position_or_address.attendee_name:
return position_or_address.attendee_name_parts if parts else position_or_address.attendee_name
elif position_or_address.order:
try:
return position_or_address.order.invoice_address.name_parts if parts else position_or_address.order.invoice_address.name
except InvoiceAddress.DoesNotExist:
pass
return {} if parts else ""
@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders")
def base_placeholders(sender, **kwargs):
from pretix.base.models import InvoiceAddress
from pretix.multidomain.urlreverse import build_absolute_uri
ph = [
@@ -315,6 +336,51 @@ def base_placeholders(sender, **kwargs):
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.modify', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.modify', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.change', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.change', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.cancel', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.cancel', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
event,
@@ -429,11 +495,7 @@ def base_placeholders(sender, **kwargs):
),
SimpleFunctionalMailTextPlaceholder(
'name', ['position_or_address'],
lambda position_or_address: (
position_or_address.name
if isinstance(position_or_address, InvoiceAddress)
else position_or_address.attendee_name
),
get_best_name,
_('John Doe'),
),
]
@@ -448,11 +510,7 @@ def base_placeholders(sender, **kwargs):
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'name_%s' % f, ['position_or_address'],
lambda position_or_address, f=f: (
position_or_address.name_parts.get(f, '')
if isinstance(position_or_address, InvoiceAddress)
else position_or_address.attendee_name_parts.get(f, '')
),
lambda position_or_address, f=f: get_best_name(position_or_address, parts=True).get(f, ''),
name_scheme['sample'][f]
))

View File

@@ -1,6 +1,6 @@
import io
import tempfile
from collections import OrderedDict
from collections import OrderedDict, namedtuple
from decimal import Decimal
from typing import Tuple
@@ -20,8 +20,9 @@ class BaseExporter:
This is the base class for all data exporters
"""
def __init__(self, event):
def __init__(self, event, progress_callback=lambda v: None):
self.event = event
self.progress_callback = progress_callback
self.is_multievent = isinstance(event, QuerySet)
if isinstance(event, QuerySet):
self.events = event
@@ -94,6 +95,7 @@ class BaseExporter:
class ListExporter(BaseExporter):
ProgressSetTotal = namedtuple('ProgressSetTotal', 'total')
@property
def export_form_fields(self) -> dict:
@@ -127,34 +129,63 @@ class ListExporter(BaseExporter):
def _render_csv(self, form_data, output_file=None, **kwargs):
if output_file:
writer = csv.writer(output_file, **kwargs)
total = 0
counter = 0
for line in self.iterate_list(form_data):
if isinstance(line, self.ProgressSetTotal):
total = line.total
continue
line = [
localize(f) if isinstance(f, Decimal) else f
for f in line
]
if total:
counter += 1
if counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100)
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', None
else:
output = io.StringIO()
writer = csv.writer(output, **kwargs)
total = 0
counter = 0
for line in self.iterate_list(form_data):
if isinstance(line, self.ProgressSetTotal):
total = line.total
continue
line = [
localize(f) if isinstance(f, Decimal) else f
for f in line
]
if total:
counter += 1
if counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100)
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data, output_file=None):
wb = Workbook()
ws = wb.active
wb = Workbook(write_only=True)
ws = wb.create_sheet()
try:
ws.title = str(self.verbose_name)
except:
pass
total = 0
counter = 0
for i, line in enumerate(self.iterate_list(form_data)):
for j, val in enumerate(line):
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
if isinstance(line, self.ProgressSetTotal):
total = line.total
continue
ws.append([
str(val) if not isinstance(val, KNOWN_TYPES) else val
for val in line
])
if total:
counter += 1
if counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100)
if output_file:
wb.save(output_file)
@@ -212,35 +243,61 @@ class MultiSheetListExporter(ListExporter):
raise NotImplementedError() # noqa
def _render_sheet_csv(self, form_data, sheet, output_file=None, **kwargs):
total = 0
counter = 0
if output_file:
writer = csv.writer(output_file, **kwargs)
for line in self.iterate_sheet(form_data, sheet):
if isinstance(line, self.ProgressSetTotal):
total = line.total
continue
line = [
localize(f) if isinstance(f, Decimal) else f
for f in line
]
writer.writerow(line)
if total:
counter += 1
if counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100)
return self.get_filename() + '.csv', 'text/csv', None
else:
output = io.StringIO()
writer = csv.writer(output, **kwargs)
for line in self.iterate_sheet(form_data, sheet):
if isinstance(line, self.ProgressSetTotal):
total = line.total
continue
line = [
localize(f) if isinstance(f, Decimal) else f
for f in line
]
writer.writerow(line)
if total:
counter += 1
if counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data, output_file=None):
wb = Workbook()
ws = wb.active
wb.remove(ws)
for s, l in self.sheets:
wb = Workbook(write_only=True)
n_sheets = len(self.sheets)
for i_sheet, (s, l) in enumerate(self.sheets):
ws = wb.create_sheet(str(l))
total = 0
counter = 0
for i, line in enumerate(self.iterate_sheet(form_data, sheet=s)):
for j, val in enumerate(line):
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
if isinstance(line, self.ProgressSetTotal):
total = line.total
continue
ws.append([
str(val) if not isinstance(val, KNOWN_TYPES) else val
for val in line
])
if total:
counter += 1
if counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100 / n_sheets + 100 / n_sheets * i_sheet)
if output_file:
wb.save(output_file)

View File

@@ -1,89 +1,33 @@
import os
import tempfile
from collections import OrderedDict
from decimal import Decimal
from zipfile import ZipFile
import dateutil.parser
from django import forms
from django.db.models import Exists, OuterRef, Q
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
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 pretix.base.models import Invoice, OrderPayment
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
from ...control.forms.filter import get_all_payment_providers
from ..exporter import BaseExporter
from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable
from ..exporter import BaseExporter, MultiSheetListExporter
from ..services.invoices import invoice_pdf_task
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
class InvoiceExporter(BaseExporter):
identifier = 'invoices'
verbose_name = _('All invoices')
def render(self, form_data: dict, output_file=None):
qs = Invoice.objects.filter(event__in=self.events, shredded=False)
if form_data.get('payment_provider'):
qs = qs.annotate(
has_payment_with_provider=Exists(
OrderPayment.objects.filter(
Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider'))
)
)
)
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)
with tempfile.TemporaryDirectory() as d:
any = False
with ZipFile(output_file or os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs:
try:
if not i.file:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
any = True
i.file.close()
except FileNotFoundError:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
any = True
i.file.close()
if not any:
return None
if self.is_multievent:
filename = '{}_invoices.zip'.format(self.events.first().organizer.slug)
else:
filename = '{}_invoices.zip'.format(self.event.slug)
if output_file:
return filename, 'application/zip', None
else:
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return filename, 'application/zip', zipf.read()
class InvoiceExporterMixin:
@property
def export_form_fields(self):
def invoice_exporter_form_fields(self):
return OrderedDict(
[
('date_from',
@@ -121,6 +65,323 @@ class InvoiceExporter(BaseExporter):
]
)
def invoices_queryset(self, form_data: dict):
qs = Invoice.objects.filter(event__in=self.events)
if form_data.get('payment_provider'):
qs = qs.annotate(
has_payment_with_provider=Exists(
OrderPayment.objects.filter(
Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider'))
)
)
)
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)
return qs
class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
identifier = 'invoices'
verbose_name = _('All invoices')
def render(self, form_data: dict, output_file=None):
qs = self.invoices_queryset(form_data).filter(shredded=False)
with tempfile.TemporaryDirectory() as d:
total = qs.count()
if not total:
return None
counter = 0
with ZipFile(output_file or os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs.iterator():
try:
if not i.file:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
i.file.close()
except FileNotFoundError:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
i.file.close()
counter += 1
if total and counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100)
if self.is_multievent:
filename = '{}_invoices.zip'.format(self.events.first().organizer.slug)
else:
filename = '{}_invoices.zip'.format(self.event.slug)
if output_file:
return filename, 'application/zip', None
else:
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return filename, 'application/zip', zipf.read()
@property
def export_form_fields(self):
return self.invoice_exporter_form_fields
class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
identifier = 'invoicedata'
verbose_name = _('Invoice data')
@property
def additional_form_fields(self):
return self.invoice_exporter_form_fields
@property
def sheets(self):
return (
('invoices', _('Invoices')),
('lines', _('Invoice lines')),
)
def iterate_sheet(self, form_data, sheet):
_ = gettext
if sheet == 'invoices':
yield [
_('Invoice number'),
_('Date'),
_('Order code'),
_('E-mail address'),
_('Invoice type'),
_('Cancellation of'),
_('Language'),
_('Invoice sender:') + ' ' + _('Name'),
_('Invoice sender:') + ' ' + _('Address'),
_('Invoice sender:') + ' ' + _('ZIP code'),
_('Invoice sender:') + ' ' + _('City'),
_('Invoice sender:') + ' ' + _('Country'),
_('Invoice sender:') + ' ' + _('Tax ID'),
_('Invoice sender:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Company'),
_('Invoice recipient:') + ' ' + _('Name'),
_('Invoice recipient:') + ' ' + _('Street address'),
_('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
_('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'),
_('Reverse charge'),
_('Shown foreign currency'),
_('Foreign currency rate'),
_('Total value (with taxes)'),
_('Total value (without taxes)'),
_('Payment matching IDs'),
_('Payment providers'),
]
p_providers = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
).values('order').annotate(
m=GroupConcat('provider', delimiter=',')
).values(
'm'
).order_by()
base_qs = self.invoices_queryset(form_data)\
qs = base_qs.select_related(
'order', 'refers'
).prefetch_related('order__payments').annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
total_gross=Subquery(
InvoiceLine.objects.filter(
invoice=OuterRef('pk')
).order_by().values('invoice').annotate(
s=Sum('gross_value')
).values('s')
),
total_net=Subquery(
InvoiceLine.objects.filter(
invoice=OuterRef('pk')
).order_by().values('invoice').annotate(
s=Sum(F('gross_value') - F('tax_value'))
).values('s')
)
)
all_ids = base_qs.order_by('full_invoice_no').values_list('pk', flat=True)
yield self.ProgressSetTotal(total=len(all_ids))
for ids in chunked_iterable(all_ids, 1000):
invs = sorted(qs.filter(id__in=ids), key=lambda k: ids.index(k.pk))
for i in invs:
pmis = []
for p in i.order.payments.all():
if p.state in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_REFUNDED):
pprov = p.payment_provider
if pprov:
mid = pprov.matching_id(p)
if mid:
pmis.append(mid)
pmi = '\n'.join(pmis)
yield [
i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
i.order.email,
_('Cancellation') if i.is_cancellation else _('Invoice'),
i.refers.full_invoice_no if i.refers else '',
i.locale,
i.invoice_from_name,
i.invoice_from,
i.invoice_from_zipcode,
i.invoice_from_city,
i.invoice_from_country,
i.invoice_from_tax_id,
i.invoice_from_vat_id,
i.invoice_to_company,
i.invoice_to_name,
i.invoice_to_street or i.invoice_to,
i.invoice_to_zipcode,
i.invoice_to_city,
i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id,
i.invoice_to_beneficiary,
i.internal_reference,
_('Yes') if i.reverse_charge else _('No'),
i.foreign_currency_display,
i.foreign_currency_rate,
i.total_gross if i.total_gross else Decimal('0.00'),
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
pmi,
', '.join([
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
if p and p != 'free'
])
]
elif sheet == 'lines':
yield [
_('Invoice number'),
_('Line number'),
_('Description'),
_('Gross price'),
_('Net price'),
_('Tax value'),
_('Tax rate'),
_('Tax name'),
_('Event start date'),
_('Date'),
_('Order code'),
_('E-mail address'),
_('Invoice type'),
_('Cancellation of'),
_('Invoice sender:') + ' ' + _('Name'),
_('Invoice sender:') + ' ' + _('Address'),
_('Invoice sender:') + ' ' + _('ZIP code'),
_('Invoice sender:') + ' ' + _('City'),
_('Invoice sender:') + ' ' + _('Country'),
_('Invoice sender:') + ' ' + _('Tax ID'),
_('Invoice sender:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Company'),
_('Invoice recipient:') + ' ' + _('Name'),
_('Invoice recipient:') + ' ' + _('Street address'),
_('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
_('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'),
_('Payment providers'),
]
p_providers = OrderPayment.objects.filter(
order=OuterRef('invoice__order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
).values('order').annotate(
m=GroupConcat('provider', delimiter=',')
).values(
'm'
).order_by()
qs = InvoiceLine.objects.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).filter(
invoice__in=self.invoices_queryset(form_data)
).order_by('invoice__full_invoice_no', 'position').select_related(
'invoice', 'invoice__order', 'invoice__refers'
)
yield self.ProgressSetTotal(total=qs.count())
for l in qs.iterator():
i = l.invoice
yield [
i.full_invoice_no,
l.position + 1,
l.description.replace("<br />", " - "),
l.gross_value,
l.net_value,
l.tax_value,
l.tax_rate,
l.tax_name,
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
i.order.email,
_('Cancellation') if i.is_cancellation else _('Invoice'),
i.refers.full_invoice_no if i.refers else '',
i.invoice_from_name,
i.invoice_from,
i.invoice_from_zipcode,
i.invoice_from_city,
i.invoice_from_country,
i.invoice_from_tax_id,
i.invoice_from_vat_id,
i.invoice_to_company,
i.invoice_to_name,
i.invoice_to_street or i.invoice_to,
i.invoice_to_zipcode,
i.invoice_to_city,
i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id,
i.invoice_to_beneficiary,
i.internal_reference,
', '.join([
str(self.providers.get(p, p)) for p in sorted(set((l.payment_providers or '').split(',')))
if p and p != 'free'
])
]
@cached_property
def providers(self):
return dict(get_all_payment_providers())
def get_filename(self):
if self.is_multievent:
return '{}_invoices'.format(self.events.first().organizer.slug)
else:
return '{}_invoices'.format(self.event.slug)
@receiver(register_data_exporters, dispatch_uid="exporter_invoices")
def register_invoice_export(sender, **kwargs):
@@ -130,3 +391,13 @@ def register_invoice_export(sender, **kwargs):
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoices")
def register_multievent_invoice_export(sender, **kwargs):
return InvoiceExporter
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
def register_invoicedata_exporter(sender, **kwargs):
return InvoiceDataExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoicedata")
def register_multievent_invoicedatae_xporter(sender, **kwargs):
return InvoiceDataExporter

View File

@@ -4,17 +4,15 @@ from decimal import Decimal
import pytz
from django import forms
from django.db.models import (
CharField, Count, DateTimeField, F, IntegerField, Max, OuterRef, Subquery,
CharField, Count, DateTimeField, IntegerField, Max, OuterRef, 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 as _, gettext_lazy, pgettext
from pretix.base.models import (
GiftCard, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
Question,
GiftCard, Invoice, InvoiceAddress, Order, OrderPosition, Question,
)
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.services.quotas import QuotaAvailability
@@ -22,6 +20,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable
from ..exporter import ListExporter, MultiSheetListExporter
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
@@ -81,6 +80,10 @@ class OrderListExporter(MultiSheetListExporter):
elif sheet == 'fees':
return self.iterate_fees(form_data)
@cached_property
def event_object_cache(self):
return {e.pk: e for e in self.events}
def iterate_orders(self, form_data: dict):
p_date = OrderPayment.objects.filter(
order=OuterRef('pk'),
@@ -100,6 +103,13 @@ class OrderListExporter(MultiSheetListExporter):
).values(
'm'
).order_by()
i_numbers = Invoice.objects.filter(
order=OuterRef('pk'),
).values('order').annotate(
m=GroupConcat('full_invoice_no', delimiter=', ')
).values(
'm'
).order_by()
s = OrderPosition.objects.filter(
order=OuterRef('pk')
@@ -107,15 +117,16 @@ class OrderListExporter(MultiSheetListExporter):
qs = Order.objects.filter(event__in=self.events).annotate(
payment_date=Subquery(p_date, output_field=DateTimeField()),
payment_providers=Subquery(p_providers, output_field=CharField()),
invoice_numbers=Subquery(i_numbers, output_field=CharField()),
pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address').prefetch_related('invoices').prefetch_related('event')
).select_related('invoice_address')
if form_data['paid_only']:
qs = qs.filter(status=Order.STATUS_PAID)
tax_rates = self._get_all_tax_rates(qs)
headers = [
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Company'), _('Name'),
_('Order time'), _('Company'), _('Name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
if name_scheme and len(name_scheme['fields']) > 1:
@@ -159,16 +170,18 @@ class OrderListExporter(MultiSheetListExporter):
)
}
for order in qs.order_by('datetime'):
tz = pytz.timezone(order.event.settings.timezone)
yield self.ProgressSetTotal(total=qs.count())
for order in qs.order_by('datetime').iterator():
tz = pytz.timezone(self.event_object_cache[order.event_id].settings.timezone)
row = [
order.event.slug,
self.event_object_cache[order.event_id].slug,
order.code,
order.total,
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
]
try:
row += [
@@ -212,7 +225,7 @@ class OrderListExporter(MultiSheetListExporter):
taxrate_values['taxsum'] + fee_taxrate_values['taxsum'],
]
row.append(', '.join([i.number for i in order.invoices.all()]))
row.append(order.invoice_numbers)
row.append(order.sales_channel)
row.append(_('Yes') if order.checkin_attention else _('No'))
row.append(order.comment or "")
@@ -247,6 +260,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Status'),
_('Email'),
_('Order date'),
_('Order time'),
_('Fee type'),
_('Description'),
_('Price'),
@@ -267,15 +281,17 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Payment providers'))
yield headers
for op in qs.order_by('order__datetime'):
yield self.ProgressSetTotal(total=qs.count())
for op in qs.order_by('order__datetime').iterator():
order = op.order
tz = pytz.timezone(order.event.settings.timezone)
row = [
order.event.slug,
self.event_object_cache[order.event_id].slug,
order.code,
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
op.get_fee_type_display(),
op.description,
op.value,
@@ -320,9 +336,10 @@ class OrderListExporter(MultiSheetListExporter):
).values(
'm'
).order_by()
qs = OrderPosition.objects.filter(
base_qs = OrderPosition.objects.filter(
order__event__in=self.events,
).annotate(
)
qs = base_qs.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related(
'order', 'order__invoice_address', 'item', 'variation',
@@ -333,6 +350,8 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
has_subevents = self.events.filter(has_subevents=True).exists()
headers = [
_('Event slug'),
_('Order code'),
@@ -340,8 +359,9 @@ class OrderListExporter(MultiSheetListExporter):
_('Status'),
_('Email'),
_('Order date'),
_('Order time'),
]
if self.events.filter(has_subevents=True).exists():
if has_subevents:
headers.append(pgettext('subevent', 'Date'))
headers.append(_('Start date'))
headers.append(_('End date'))
@@ -397,96 +417,107 @@ class OrderListExporter(MultiSheetListExporter):
yield headers
for op in qs.order_by('order__datetime', 'positionid'):
order = op.order
tz = pytz.timezone(order.event.settings.timezone)
row = [
order.event.slug,
order.code,
op.positionid,
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
]
if order.event.has_subevents:
row.append(op.subevent.name)
row.append(op.subevent.date_from.astimezone(order.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
if op.subevent.date_to:
row.append(op.subevent.date_to.astimezone(order.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
else:
row.append('')
row += [
str(op.item),
str(op.variation) if op.variation else '',
op.price,
op.tax_rate,
str(op.tax_rule) if op.tax_rule else '',
op.tax_value,
op.attendee_name,
]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
op.attendee_name_parts.get(k, '')
)
row += [
op.attendee_email,
op.company or '',
op.street or '',
op.zipcode or '',
op.city or '',
op.country if op.country else '',
op.state or '',
op.voucher.code if op.voucher else '',
op.pseudonymization_id,
]
acache = {}
for a in op.answers.all():
# We do not want to localize Date, Time and Datetime question answers, as those can lead
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
if a.question.type == Question.TYPE_CHOICE_MULTIPLE:
acache[a.question_id] = set(o.pk for o in a.options.all())
elif a.question.type in Question.UNLOCALIZED_TYPES:
acache[a.question_id] = a.answer
else:
acache[a.question_id] = str(a)
for q in questions:
if q.type == Question.TYPE_CHOICE_MULTIPLE:
for o in options[q.pk]:
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
else:
row.append(acache.get(q.pk, ''))
all_ids = list(base_qs.order_by('order__datetime', 'positionid').values_list('pk', flat=True))
yield self.ProgressSetTotal(total=len(all_ids))
for ids in chunked_iterable(all_ids, 1000):
ops = sorted(qs.filter(id__in=ids), key=lambda k: ids.index(k.pk))
try:
for op in ops:
order = op.order
tz = pytz.timezone(self.event_object_cache[order.event_id].settings.timezone)
row = [
self.event_object_cache[order.event_id].slug,
order.code,
op.positionid,
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
]
if has_subevents:
if op.subevent:
row.append(op.subevent.name)
row.append(op.subevent.date_from.astimezone(self.event_object_cache[order.event_id].timezone).strftime('%Y-%m-%d %H:%M:%S'))
if op.subevent.date_to:
row.append(op.subevent.date_to.astimezone(self.event_object_cache[order.event_id].timezone).strftime('%Y-%m-%d %H:%M:%S'))
else:
row.append('')
else:
row.append('')
row.append('')
row.append('')
row += [
order.invoice_address.company,
order.invoice_address.name,
str(op.item),
str(op.variation) if op.variation else '',
op.price,
op.tax_rate,
str(op.tax_rule) if op.tax_rule else '',
op.tax_value,
op.attendee_name,
]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
order.invoice_address.name_parts.get(k, '')
op.attendee_name_parts.get(k, '')
)
row += [
order.invoice_address.street,
order.invoice_address.zipcode,
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.vat_id,
op.attendee_email,
op.company or '',
op.street or '',
op.zipcode or '',
op.city or '',
op.country if op.country else '',
op.state or '',
op.voucher.code if op.voucher else '',
op.pseudonymization_id,
]
except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
row += [
order.sales_channel,
order.locale
]
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
]))
yield row
acache = {}
for a in op.answers.all():
# We do not want to localize Date, Time and Datetime question answers, as those can lead
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
if a.question.type == Question.TYPE_CHOICE_MULTIPLE:
acache[a.question_id] = set(o.pk for o in a.options.all())
elif a.question.type in Question.UNLOCALIZED_TYPES:
acache[a.question_id] = a.answer
else:
acache[a.question_id] = str(a)
for q in questions:
if q.type == Question.TYPE_CHOICE_MULTIPLE:
for o in options[q.pk]:
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
else:
row.append(acache.get(q.pk, ''))
try:
row += [
order.invoice_address.company,
order.invoice_address.name,
]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
order.invoice_address.name_parts.get(k, '')
)
row += [
order.invoice_address.street,
order.invoice_address.zipcode,
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
row += [
order.sales_channel,
order.locale
]
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
]))
yield row
def get_filename(self):
if self.is_multievent:
@@ -543,6 +574,7 @@ class PaymentListExporter(ListExporter):
]
yield headers
yield self.ProgressSetTotal(total=len(objs))
for obj in objs:
tz = pytz.timezone(obj.order.event.settings.timezone)
if isinstance(obj, OrderPayment) and obj.payment_date:
@@ -606,230 +638,6 @@ class QuotaListExporter(ListExporter):
return '{}_quotas'.format(self.event.slug)
class InvoiceDataExporter(MultiSheetListExporter):
identifier = 'invoicedata'
verbose_name = gettext_lazy('Invoice data')
@property
def sheets(self):
return (
('invoices', _('Invoices')),
('lines', _('Invoice lines')),
)
def iterate_sheet(self, form_data, sheet):
if sheet == 'invoices':
yield [
_('Invoice number'),
_('Date'),
_('Order code'),
_('E-mail address'),
_('Invoice type'),
_('Cancellation of'),
_('Language'),
_('Invoice sender:') + ' ' + _('Name'),
_('Invoice sender:') + ' ' + _('Address'),
_('Invoice sender:') + ' ' + _('ZIP code'),
_('Invoice sender:') + ' ' + _('City'),
_('Invoice sender:') + ' ' + _('Country'),
_('Invoice sender:') + ' ' + _('Tax ID'),
_('Invoice sender:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Company'),
_('Invoice recipient:') + ' ' + _('Name'),
_('Invoice recipient:') + ' ' + _('Street address'),
_('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
_('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'),
_('Reverse charge'),
_('Shown foreign currency'),
_('Foreign currency rate'),
_('Total value (with taxes)'),
_('Total value (without taxes)'),
_('Payment matching IDs'),
_('Payment providers'),
]
p_providers = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
).values('order').annotate(
m=GroupConcat('provider', delimiter=',')
).values(
'm'
).order_by()
qs = Invoice.objects.filter(event__in=self.events).order_by('full_invoice_no').select_related(
'order', 'refers'
).prefetch_related('order__payments').annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
total_gross=Subquery(
InvoiceLine.objects.filter(
invoice=OuterRef('pk')
).order_by().values('invoice').annotate(
s=Sum('gross_value')
).values('s')
),
total_net=Subquery(
InvoiceLine.objects.filter(
invoice=OuterRef('pk')
).order_by().values('invoice').annotate(
s=Sum(F('gross_value') - F('tax_value'))
).values('s')
)
)
for i in qs:
pmis = []
for p in i.order.payments.all():
if p.state in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_REFUNDED):
pprov = p.payment_provider
if pprov:
mid = pprov.matching_id(p)
if mid:
pmis.append(mid)
pmi = '\n'.join(pmis)
yield [
i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
i.order.email,
_('Cancellation') if i.is_cancellation else _('Invoice'),
i.refers.full_invoice_no if i.refers else '',
i.locale,
i.invoice_from_name,
i.invoice_from,
i.invoice_from_zipcode,
i.invoice_from_city,
i.invoice_from_country,
i.invoice_from_tax_id,
i.invoice_from_vat_id,
i.invoice_to_company,
i.invoice_to_name,
i.invoice_to_street or i.invoice_to,
i.invoice_to_zipcode,
i.invoice_to_city,
i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id,
i.invoice_to_beneficiary,
i.internal_reference,
_('Yes') if i.reverse_charge else _('No'),
i.foreign_currency_display,
i.foreign_currency_rate,
i.total_gross if i.total_gross else Decimal('0.00'),
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
pmi,
', '.join([
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
if p and p != 'free'
])
]
elif sheet == 'lines':
yield [
_('Invoice number'),
_('Line number'),
_('Description'),
_('Gross price'),
_('Net price'),
_('Tax value'),
_('Tax rate'),
_('Tax name'),
_('Event start date'),
_('Date'),
_('Order code'),
_('E-mail address'),
_('Invoice type'),
_('Cancellation of'),
_('Invoice sender:') + ' ' + _('Name'),
_('Invoice sender:') + ' ' + _('Address'),
_('Invoice sender:') + ' ' + _('ZIP code'),
_('Invoice sender:') + ' ' + _('City'),
_('Invoice sender:') + ' ' + _('Country'),
_('Invoice sender:') + ' ' + _('Tax ID'),
_('Invoice sender:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Company'),
_('Invoice recipient:') + ' ' + _('Name'),
_('Invoice recipient:') + ' ' + _('Street address'),
_('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
_('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'),
_('Payment providers'),
]
p_providers = OrderPayment.objects.filter(
order=OuterRef('invoice__order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
).values('order').annotate(
m=GroupConcat('provider', delimiter=',')
).values(
'm'
).order_by()
qs = InvoiceLine.objects.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).filter(
invoice__event__in=self.events
).order_by('invoice__full_invoice_no', 'position').select_related(
'invoice', 'invoice__order', 'invoice__refers'
)
for l in qs:
i = l.invoice
yield [
i.full_invoice_no,
l.position + 1,
l.description.replace("<br />", " - "),
l.gross_value,
l.net_value,
l.tax_value,
l.tax_rate,
l.tax_name,
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
i.order.email,
_('Cancellation') if i.is_cancellation else _('Invoice'),
i.refers.full_invoice_no if i.refers else '',
i.invoice_from_name,
i.invoice_from,
i.invoice_from_zipcode,
i.invoice_from_city,
i.invoice_from_country,
i.invoice_from_tax_id,
i.invoice_from_vat_id,
i.invoice_to_company,
i.invoice_to_name,
i.invoice_to_street or i.invoice_to,
i.invoice_to_zipcode,
i.invoice_to_city,
i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id,
i.invoice_to_beneficiary,
i.internal_reference,
', '.join([
str(self.providers.get(p, p)) for p in sorted(set((l.payment_providers or '').split(',')))
if p and p != 'free'
])
]
@cached_property
def providers(self):
return dict(get_all_payment_providers())
def get_filename(self):
if self.is_multievent:
return '{}_invoices'.format(self.events.first().organizer.slug)
else:
return '{}_invoices'.format(self.event.slug)
class GiftcardRedemptionListExporter(ListExporter):
identifier = 'giftcardredemptionlist'
verbose_name = gettext_lazy('Gift card redemptions')
@@ -897,16 +705,6 @@ def register_quotalist_exporter(sender, **kwargs):
return QuotaListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
def register_invoicedata_exporter(sender, **kwargs):
return InvoiceDataExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoicedata")
def register_multievent_invoicedatae_xporter(sender, **kwargs):
return InvoiceDataExporter
@receiver(register_data_exporters, dispatch_uid="exporter_giftcardredemptionlist")
def register_giftcardredemptionlist_exporter(sender, **kwargs):
return GiftcardRedemptionListExporter

View File

@@ -184,6 +184,10 @@ class NamePartsFormField(forms.MultiValueField):
raise forms.ValidationError(self.error_messages['required'], code='required')
if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
if sum(len(v) for v in value if v) > 250:
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
return value
@@ -397,13 +401,14 @@ class BaseQuestionsForm(forms.Form):
)
elif q.type == Question.TYPE_COUNTRYCODE:
field = CountryField(
countries=CachedCountries
countries=CachedCountries,
blank=True, null=True, blank_label=' ',
).formfield(
label=label, required=required,
help_text=help_text,
widget=forms.Select,
empty_label='',
initial=initial.answer if initial else guess_country(event),
empty_label=' ',
initial=initial.answer if initial else (guess_country(event) if required else None),
)
elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
@@ -549,7 +554,8 @@ class BaseQuestionsForm(forms.Form):
if not self.all_optional:
for q in question_cache.values():
if question_is_required(q) and not d.get('question_%d' % q.pk):
answer = d.get('question_%d' % q.pk)
if question_is_required(q) and not answer and answer != 0:
raise ValidationError({'question_%d' % q.pk: [_('This field is required')]})
return d

View File

@@ -4,6 +4,9 @@ from django.conf import settings
from django.utils import translation
from django.utils.formats import date_format, number_format
from django.utils.translation import gettext
from pretix.base.templatetags.money import money_filter
from i18nfield.fields import ( # noqa
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
)
@@ -12,8 +15,6 @@ from i18nfield.forms import I18nFormField # noqa
from i18nfield.strings import LazyI18nString # noqa
from i18nfield.utils import I18nJSONEncoder # noqa
from pretix.base.templatetags.money import money_filter
class LazyDate:
def __init__(self, value):

View File

@@ -391,7 +391,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p_size = p.wrap(self.event_width, self.event_height)
return txt
if not self.invoice.event.has_subevents or not self.invoice.event.settings.show_dates_on_frontpage:
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
p_str = (
shorten(self.invoice.event.name) + '\n' +

View File

@@ -4,6 +4,7 @@ import sys
from django.core.management.base import BaseCommand
from django.utils.timezone import override
from django_scopes import scope
from tqdm import tqdm
from pretix.base.i18n import language
from pretix.base.models import Event, Organizer
@@ -34,10 +35,15 @@ class Command(BaseCommand):
self.stderr.write(self.style.ERROR('Event not found.'))
sys.exit(1)
pbar = tqdm(total=100)
def report_status(val):
pbar.update(round(val, 2) - pbar.n)
with language(e.settings.locale), override(e.settings.timezone):
responses = register_data_exporters.send(e)
for receiver, response in responses:
ex = response(e)
ex = response(e, report_status)
if ex.identifier == options['export_provider'][0]:
params = json.loads(options.get('parameters') or '{}')
with open(options['output_file'][0], 'wb') as f:
@@ -53,6 +59,7 @@ class Command(BaseCommand):
f.write(d[2])
sys.exit(0)
pbar.close()
self.stderr.write(self.style.ERROR('Export provider not found.'))
sys.exit(1)

View File

@@ -18,6 +18,7 @@ class Command(BaseCommand):
cmd = 'shell_plus'
except ImportError:
cmd = 'shell'
del options['skip_checks']
parser = self.create_parser(sys.argv[0], sys.argv[1])
flags = parser.parse_known_args(sys.argv[2:])[1]

View File

@@ -3,6 +3,9 @@ from collections import defaultdict
from django.apps import apps
from django.conf import settings
from django.db import connection
from pretix.base.models import Event, Invoice, Order, OrderPosition, Organizer
if settings.HAS_REDIS:
import django_redis
@@ -201,6 +204,19 @@ class Histogram(Metric):
self._execute_redis_pipeline(pipe)
def estimate_count_fast(type):
"""
See https://wiki.postgresql.org/wiki/Count_estimate
"""
if 'postgres' in settings.DATABASES['default']['ENGINE']:
cursor = connection.cursor()
cursor.execute("select reltuples from pg_class where relname='%s';" % type._meta.db_table)
row = cursor.fetchone()
return int(row[0])
else:
return type.objects.count()
def metric_values():
"""
Produces the the values to be presented to the monitoring system
@@ -223,8 +239,14 @@ def metric_values():
metrics[a] = metrics[atarget]
# Throwaway metrics
exact_tables = [
Order, OrderPosition, Invoice, Event, Organizer
]
for m in apps.get_models(): # Count all models
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
if any(issubclass(m, p) for p in exact_tables):
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
else:
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
return metrics

View File

@@ -199,9 +199,7 @@ class SecurityMiddleware(MiddlewareMixin):
'default-src': ["{static}"],
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
'object-src': ["'none'"],
# frame-src is deprecated but kept for compatibility with CSP 1.0 browsers, e.g. Safari 9
'frame-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
'child-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
'style-src': ["{static}", "{media}"],
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"] + img_src,

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.6 on 2020-07-12 09:32
from django.db import migrations, models
import pretix.helpers.countries
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0156_cartposition_override_tax_rate'),
]
operations = [
migrations.AddField(
model_name='itemaddon',
name='multi_allowed',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 3.0.8 on 2020-07-24 07:54
from django.db import migrations, models
import pretix.base.models.base
import pretix.helpers.countries
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0157_auto_20200712_0932'),
]
operations = [
migrations.AlterField(
model_name='cachedfile',
name='file',
field=models.FileField(max_length=255, null=True, upload_to=pretix.base.models.base.cachedfile_name),
),
migrations.AlterField(
model_name='cartposition',
name='country',
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True),
),
migrations.AlterField(
model_name='invoice',
name='invoice_from_country',
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True),
),
migrations.AlterField(
model_name='invoice',
name='invoice_to_country',
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True),
),
migrations.AlterField(
model_name='invoiceaddress',
name='country',
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2),
),
migrations.AlterField(
model_name='orderposition',
name='country',
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True),
),
migrations.AlterField(
model_name='taxrule',
name='home_country',
field=pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2),
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 3.0.8 on 2020-07-24 09:05
from django.db import migrations
from pretix.base.channels import get_all_sales_channels
def set_sales_channels(apps, schema_editor):
# We now allow restricting some mails to certain sales channels
# The default is changing from all channels to "web" only
# Therefore, for existing events, we enable all sales channels
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
Event = apps.get_model('pretixbase', 'Event')
all_sales_channels = "[" + ", ".join('"' + sc + '"' for sc in get_all_sales_channels()) + "]"
batch_size = 1000
Event_SettingsStore.objects.bulk_create([
Event_SettingsStore(
object=event,
key="mail_sales_channel_placed_paid",
value=all_sales_channels)
for event in Event.objects.all()
], batch_size=batch_size)
Event_SettingsStore.objects.bulk_create([
Event_SettingsStore(
object=event,
key="mail_sales_channel_download_reminder",
value=all_sales_channels)
for event in Event.objects.all()
], batch_size=batch_size)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0158_auto_20200724_0754'),
]
operations = [
migrations.RunPython(set_sales_channels, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.0.8 on 2020-07-31 10:05
import json
from django.db import migrations
def migrate_confirm_text(apps, schema_editor):
# We now allow creating multiple confirm texts so we migrate the setting for that
# from `confirm_text` to `confirm_texts`
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
for store in Event_SettingsStore.objects.filter(key="confirm_text"):
if store.value:
values = json.dumps([json.loads(store.value)]) # convert single value to one-element list
store.key = "confirm_texts"
store.value = values
store.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0159_mails_by_sales_channel'),
]
operations = [
migrations.RunPython(migrate_confirm_text, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,20 @@
from django.db import migrations
def migrate_change_allow_user_price(apps, schema_editor):
# Previously, the "gt" value was meant to represent "greater or equal", which became an issue the moment
# we introduced a "greater" and "greater or equal" option. This migrates any previous "greater or equal"
# selection to the new "gte".
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
Event_SettingsStore.objects.filter(key="change_allow_user_price", value="gt").update(value="gte")
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0160_multiple_confirm_texts'),
]
operations = [
migrations.RunPython(migrate_change_allow_user_price, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.0.9 on 2020-08-24 07:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0161_order_changes_retain_old_default'),
]
operations = [
migrations.RemoveField(
model_name='seat',
name='name',
),
]

View File

@@ -173,7 +173,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return self.email
def send_security_notice(self, messages, email=None):
from pretix.base.services.mail import mail, SendMailException
from pretix.base.services.mail import SendMailException, mail
try:
with language(self.locale):

View File

@@ -27,7 +27,7 @@ class CachedFile(models.Model):
date = models.DateTimeField(null=True, blank=True)
filename = models.CharField(max_length=255)
type = models.CharField(max_length=255)
file = models.FileField(null=True, blank=True, upload_to=cachedfile_name)
file = models.FileField(null=True, blank=True, upload_to=cachedfile_name, max_length=255)
@receiver(post_delete, sender=CachedFile)
@@ -48,14 +48,15 @@ class LoggingMixin:
:param data: Any JSON-serializable object
:param user: The user performing the action (optional)
"""
from .log import LogEntry
from .event import Event
from .devices import Device
from pretix.api.models import OAuthAccessToken, OAuthApplication
from .organizer import TeamAPIToken
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
from ..notifications import get_all_notification_types
from ..services.notifications import notify
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
from .devices import Device
from .event import Event
from .log import LogEntry
from .organizer import TeamAPIToken
event = None
if isinstance(self, Event):

View File

@@ -1,8 +1,9 @@
from django.conf import settings
from django.db import models
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from django_scopes import ScopedManager, scopes_disabled
from jsonfallback.fields import FallbackJSONField
from pretix.base.models import LoggedModel
@@ -47,7 +48,7 @@ class CheckinList(LoggedModel):
@property
def positions(self):
from . import OrderPosition, Order
from . import Order, OrderPosition
qs = OrderPosition.objects.filter(
order__event=self.event,
@@ -89,11 +90,14 @@ class CheckinList(LoggedModel):
).count()
@property
@scopes_disabled()
# Disable scopes, because this query is safe and the additional organizer filter in the EXISTS() subquery tricks PostgreSQL into a bad
# subplan that sequentially scans all events
def checkin_count(self):
return self.event.cache.get_or_set(
'checkin_list_{}_checkin_count'.format(self.pk),
lambda: self.positions.annotate(
checkedin=Exists(Checkin.objects.filter(list_id=self.pk, position=OuterRef('pk')))
lambda: self.positions.using(settings.DATABASE_REPLICA).annotate(
checkedin=Exists(Checkin.objects.filter(list_id=self.pk, position=OuterRef('pk'), type=Checkin.TYPE_ENTRY,))
).filter(
checkedin=True
).count(),

View File

@@ -415,7 +415,7 @@ class Event(EventMixin, LoggedModel):
return super().presale_has_ended
def delete_all_orders(self, really=False):
from .orders import OrderRefund, OrderPayment, OrderPosition, OrderFee
from .orders import OrderFee, OrderPayment, OrderPosition, OrderRefund
if not really:
raise TypeError("Pass really=True as a parameter.")
@@ -502,8 +502,10 @@ class Event(EventMixin, LoggedModel):
), tz)
def copy_data_from(self, other):
from . import ItemAddOn, ItemCategory, Item, Question, Quota, ItemMetaValue
from ..signals import event_copy_data
from . import (
Item, ItemAddOn, ItemCategory, ItemMetaValue, Question, Quota,
)
self.plugins = other.plugins
self.is_public = other.is_public
@@ -883,6 +885,7 @@ class Event(EventMixin, LoggedModel):
def delete_sub_objects(self):
self.cartposition_set.filter(addon_to__isnull=False).delete()
self.cartposition_set.all().delete()
self.vouchers.all().delete()
self.items.all().delete()
self.subevents.all().delete()
@@ -1111,12 +1114,28 @@ class SubEvent(EventMixin, LoggedModel):
if self.event and clear_cache:
self.event.cache.clear()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__original_dates = (self.date_from, self.date_to)
def save(self, *args, **kwargs):
from .orders import Order
clear_cache = kwargs.pop('clear_cache', False)
super().save(*args, **kwargs)
if self.event and clear_cache:
self.event.cache.clear()
if (self.date_from, self.date_to) != self.__original_dates:
"""
This is required to guarantee a synchronization invariant of our scanning apps.
Our syncing apps throw away order records of subevents more than X days ago, since
they are not interesting for ticket scanning and pose a performance hazard. However,
the app needs to know when a subevent is moved to a date in the future, since that
might require it to re-download and re-store the orders.
"""
Order.objects.filter(all_positions__subevent=self).update(last_modified=now())
@staticmethod
def clean_items(event, items):
for item in items:
@@ -1183,8 +1202,8 @@ class RequiredAction(models.Model):
created = not self.pk
super().save(*args, **kwargs)
if created:
from .log import LogEntry
from ..services.notifications import notify
from .log import LogEntry
logentry = LogEntry.objects.create(
content_object=self,

View File

@@ -116,8 +116,8 @@ class Invoice(models.Model):
objects = ScopedManager(organizer='event__organizer')
@staticmethod
def _to_numeric_invoice_number(number):
return '{:05d}'.format(int(number))
def _to_numeric_invoice_number(number, places):
return ('{:0%dd}' % places).format(int(number))
@property
def full_invoice_from(self):
@@ -173,7 +173,7 @@ class Invoice(models.Model):
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
def _get_numeric_invoice_number(self):
def _get_numeric_invoice_number(self, c_length):
numeric_invoices = Invoice.objects.filter(
event__organizer=self.event.organizer,
prefix=self.prefix,
@@ -182,7 +182,7 @@ class Invoice(models.Model):
).aggregate(
max=Max('numeric_number')
)['max'] or 0
return self._to_numeric_invoice_number(numeric_invoices + 1)
return self._to_numeric_invoice_number(numeric_invoices + 1, c_length)
def _get_invoice_number_from_order(self):
return '{order}-{count}'.format(
@@ -209,7 +209,7 @@ class Invoice(models.Model):
self.prefix += 'TEST-'
for i in range(10):
if self.event.settings.get('invoice_numbers_consecutive'):
self.invoice_no = self._get_numeric_invoice_number()
self.invoice_no = self._get_numeric_invoice_number(self.event.settings.invoice_numbers_counter_length)
else:
self.invoice_no = self._get_invoice_number_from_order()
try:

View File

@@ -118,7 +118,7 @@ class SubEventItem(models.Model):
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
item = models.ForeignKey('Item', on_delete=models.CASCADE)
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
disabled = models.BooleanField(default=False)
disabled = models.BooleanField(default=False, verbose_name=_('Disable product for this date'))
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -378,9 +378,9 @@ class Item(LoggedModel):
'but only for fixed bundles!')
)
allow_cancel = models.BooleanField(
verbose_name=_('Allow product to be canceled'),
verbose_name=_('Allow product to be canceled or changed'),
default=True,
help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, '
help_text=_('If this is checked, the usual cancellation and order change settings of this event apply. If this is unchecked, '
'orders containing this product can not be canceled by users but only by you.')
)
min_per_order = models.IntegerField(
@@ -412,7 +412,8 @@ class Item(LoggedModel):
)
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=['web']
default=['web'],
blank=True,
)
issue_giftcard = models.BooleanField(
verbose_name=_('This product is a gift card'),
@@ -840,6 +841,10 @@ class ItemAddOn(models.Model):
help_text=_('If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost '
'money individually.')
)
multi_allowed = models.BooleanField(
default=False,
verbose_name=_('Allow the same product to be selected multiple times'),
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")

View File

@@ -75,7 +75,10 @@ class LogEntry(models.Model):
@cached_property
def display_object(self):
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
from . import (
Event, Item, ItemCategory, Order, Question, Quota, SubEvent,
TaxRule, Voucher,
)
try:
if self.content_type.model_class() is Event:

View File

@@ -209,7 +209,7 @@ class Order(LockModel, LoggedModel):
return self.full_code
def gracefully_delete(self, user=None, auth=None):
from . import Voucher, GiftCard, GiftCardTransaction
from . import GiftCard, GiftCardTransaction, Voucher
if not self.testmode:
raise TypeError("Only test mode orders can be deleted.")
@@ -434,6 +434,19 @@ class Order(LockModel, LoggedModel):
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED) and self.count_positions
)
@cached_property
def user_change_deadline(self):
until = self.event.settings.get('change_allow_user_until', as_type=RelativeDateWrapper)
if until:
if self.event.has_subevents:
terms = [
until.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
]
return min(terms) if terms else None
else:
return until.datetime(self.event)
@cached_property
def user_cancel_deadline(self):
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
@@ -466,6 +479,40 @@ class Order(LockModel, LoggedModel):
fee += self.event.settings.cancel_allow_user_paid_keep
return round_decimal(fee, self.event.currency)
@property
@scopes_disabled()
def user_change_allowed(self) -> bool:
"""
Returns whether or not this order can be canceled by the user.
"""
from .checkin import Checkin
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID) or not self.count_positions:
return False
if self.cancellation_requests.exists():
return False
if self.require_approval:
return False
positions = list(
self.positions.all().annotate(
has_variations=Exists(ItemVariation.objects.filter(item_id=OuterRef('item_id'))),
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
).select_related('item').prefetch_related('issued_gift_cards')
)
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
if not cancelable or not positions:
return False
for op in positions:
if op.issued_gift_cards.all():
return False
if self.user_change_deadline and now() > self.user_change_deadline:
return False
return self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])
@property
@scopes_disabled()
def user_cancel_allowed(self) -> bool:
@@ -474,7 +521,7 @@ class Order(LockModel, LoggedModel):
"""
from .checkin import Checkin
if self.cancellation_requests.exists():
if self.cancellation_requests.exists() or not self.cancel_allowed():
return False
positions = list(
self.positions.all().annotate(
@@ -795,7 +842,9 @@ class Order(LockModel, LoggedModel):
only be attached for this position and child positions, the link will only point to the
position and the attendee email will be used if available.
"""
from pretix.base.services.mail import SendMailException, mail, render_mail, TolerantDict
from pretix.base.services.mail import (
SendMailException, TolerantDict, mail, render_mail,
)
if not self.email:
return
@@ -1378,7 +1427,9 @@ class OrderPayment(models.Model):
:type mail_text: str
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
"""
from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.invoices import (
generate_invoice, invoice_qualified,
)
with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
@@ -1448,7 +1499,7 @@ class OrderPayment(models.Model):
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
)
if send_mail:
if send_mail and self.order.sales_channel in self.order.event.settings.mail_sales_channel_placed_paid:
self._send_paid_mail(invoice, user, mail_text)
if self.order.event.settings.mail_send_order_paid_attendee:
for p in self.order.positions.all():
@@ -1822,6 +1873,9 @@ class OrderFee(models.Model):
self.tax_rate = Decimal('0.00')
def save(self, *args, **kwargs):
if self.tax_rule and not self.tax_rule.rate and not self.tax_rule.pk:
self.tax_rule = None
if self.tax_rate is None:
self._calculate_tax()
self.order.touch()
@@ -2023,7 +2077,9 @@ class OrderPosition(AbstractPosition):
:param sender: Custom email sender.
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
"""
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.base.services.mail import (
SendMailException, mail, render_mail,
)
if not self.attendee_email:
return

View File

@@ -113,7 +113,7 @@ class Organizer(LoggedModel):
), tz)
def allow_delete(self):
from . import Order, Invoice
from . import Invoice, Order
return (
not Order.objects.filter(event__organizer=self).exists() and
not Invoice.objects.filter(event__organizer=self).exists() and

View File

@@ -42,7 +42,7 @@ class SeatingPlan(LoggedModel):
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
Category = namedtuple('Categrory', 'name')
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank row_label seat_label x y')
RawSeat = namedtuple('Seat', 'guid number row category zone sorting_rank row_label seat_label x y')
def __str__(self):
return self.name
@@ -95,7 +95,6 @@ class SeatingPlan(LoggedModel):
yield self.RawSeat(
number=s['seat_number'],
guid=s['seat_guid'],
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
row=r['row_number'],
row_label=row_label,
seat_label=seat_label,
@@ -125,7 +124,6 @@ class Seat(models.Model):
"""
event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
name = models.CharField(max_length=190)
zone_name = models.CharField(max_length=190, blank=True, default="")
row_name = models.CharField(max_length=190, blank=True, default="")
row_label = models.CharField(max_length=190, null=True)
@@ -141,6 +139,10 @@ class Seat(models.Model):
class Meta:
ordering = ['sorting_rank', 'seat_guid']
@property
def name(self):
return str(self)
def __str__(self):
parts = []
if self.zone_name:
@@ -163,7 +165,7 @@ class Seat(models.Model):
@classmethod
def annotated(cls, qs, event_id, subevent, ignore_voucher_id=None, minimal_distance=0,
ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False):
from . import Order, OrderPosition, Voucher, CartPosition
from . import CartPosition, Order, OrderPosition, Voucher
vqs = Voucher.objects.filter(
event_id=event_id,
@@ -253,15 +255,18 @@ class Seat(models.Model):
ignore_order_id=ignore_orderpos.order_id if ignore_orderpos else None,
ignore_cart_id=(
distance_ignore_cart_id or
(ignore_cart.cart_id if ignore_cart else None)
(ignore_cart.cart_id if ignore_cart and ignore_cart is not True else None)
))
q = Q(has_order=True) | Q(has_voucher=True)
if ignore_cart is not True:
q |= Q(has_cart=True)
qs_closeby_taken = qs_annotated.annotate(
distance=(
Power(F('x') - Value(self.x), Value(2), output_field=models.FloatField()) +
Power(F('y') - Value(self.y), Value(2), output_field=models.FloatField())
)
).exclude(pk=self.pk).filter(
Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True),
q,
distance__lt=self.event.settings.seating_minimal_distance ** 2
)
if self.event.settings.seating_distance_within_row:

View File

@@ -206,7 +206,12 @@ class TaxRule(LoggedModel):
base_price_is = 'net'
if base_price_is == 'gross':
gross = max(Decimal('0.00'), base_price - subtract_from_gross)
if base_price >= Decimal('0.00'):
# For positive prices, make sure they don't go negative because of bundles
gross = max(Decimal('0.00'), base_price - subtract_from_gross)
else:
# If the price is already negative, we don't really care any more
gross = base_price - subtract_from_gross
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
currency)
elif base_price_is == 'net':

View File

@@ -49,6 +49,7 @@ class PaymentProviderForm(Form):
val = cleaned_data.get(k)
if v._required and not val:
self.add_error(k, _('This field is required.'))
return cleaned_data
class BasePaymentProvider:
@@ -167,13 +168,23 @@ class BasePaymentProvider:
@property
def abort_pending_allowed(self) -> bool:
"""
Whether or not a user can abort a payment in pending start to switch to another
Whether or not a user can abort a payment in pending state to switch to another
payment method. This returns ``False`` by default which is no guarantee that
aborting a pending payment can never happen, it just hides the frontend button
to avoid users accidentally committing double payments.
"""
return False
@property
def requires_invoice_immediately(self):
"""
Return whether this payment method requires an invoice to exist for an order, even though the event
is configured to only create invoices for paid orders.
By default this is False, but it might be overwritten for e.g. bank transfer.
`execute_payment` is called after the invoice is created.
"""
return False
@property
def settings_form_fields(self) -> dict:
"""
@@ -374,6 +385,8 @@ class BasePaymentProvider:
"""
return {}
payment_form_class = PaymentProviderForm
def payment_form(self, request: HttpRequest) -> Form:
"""
This is called by the default implementation of :py:meth:`payment_form_render`
@@ -386,7 +399,7 @@ class BasePaymentProvider:
``PaymentProviderForm`` (from this module) that handles some nasty issues about
required fields for you.
"""
form = PaymentProviderForm(
form = self.payment_form_class(
data=(request.POST if request.method == 'POST' and request.POST.get("payment") == self.identifier else None),
prefix='payment_%s' % self.identifier,
initial={
@@ -767,7 +780,7 @@ class BasePaymentProvider:
def matching_id(self, payment: OrderPayment):
"""
Will be called to get an ID for a matching this payment when comparing pretix records with records of an external
Will be called to get an ID for matching this payment when comparing pretix records with records of an external
source. This should return the main transaction ID for your API.
:param payment: The payment in question.
@@ -1062,7 +1075,10 @@ class GiftCardPayment(BasePaymentProvider):
def api_payment_details(self, payment: OrderPayment):
from .models import GiftCard
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
try:
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
except GiftCard.DoesNotExist:
return {}
return {
'gift_card': {
'id': gc.pk,

View File

@@ -492,7 +492,10 @@ class Renderer:
'support_ligatures': False,
}
reshaper = ArabicReshaper(configuration=configuration)
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
try:
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style)
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
@@ -544,7 +547,7 @@ class Renderer:
with open(os.path.join(d, 'out.pdf'), 'rb') as f:
return BytesIO(f.read())
else:
from PyPDF2 import PdfFileWriter, PdfFileReader
from PyPDF2 import PdfFileReader, PdfFileWriter
buffer.seek(0)
new_pdf = PdfFileReader(buffer)
output = PdfFileWriter()

View File

@@ -18,7 +18,7 @@ BASE_CHOICES = (
('presale_end', _('Presale end')),
)
RelativeDate = namedtuple('RelativeDate', ['days_before', 'time', 'base_date_name'])
RelativeDate = namedtuple('RelativeDate', ['days_before', 'minutes_before', 'time', 'base_date_name'])
class RelativeDateWrapper:
@@ -35,7 +35,7 @@ class RelativeDateWrapper:
def __init__(self, data: Union[datetime.datetime, RelativeDate]):
self.data = data
def date(self, event) -> datetime.datetime:
def date(self, event) -> datetime.date:
from .models import SubEvent
if isinstance(self.data, datetime.date):
@@ -43,6 +43,9 @@ class RelativeDateWrapper:
elif isinstance(self.data, datetime.datetime):
return self.data.date()
else:
if self.data.minutes_before is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
tz = pytz.timezone(event.settings.timezone)
if isinstance(event, SubEvent):
base_date = (
@@ -72,23 +75,31 @@ class RelativeDateWrapper:
else:
base_date = getattr(event, self.data.base_date_name) or event.date_from
oldoffset = base_date.astimezone(tz).utcoffset()
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
if self.data.time:
new_date = new_date.replace(
hour=self.data.time.hour,
minute=self.data.time.minute,
second=self.data.time.second
)
new_date = new_date.astimezone(tz)
newoffset = new_date.utcoffset()
new_date += oldoffset - newoffset
return new_date
if self.data.minutes_before is not None:
return base_date.astimezone(tz) - datetime.timedelta(minutes=self.data.minutes_before)
else:
oldoffset = base_date.astimezone(tz).utcoffset()
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
if self.data.time:
new_date = new_date.replace(
hour=self.data.time.hour,
minute=self.data.time.minute,
second=self.data.time.second
)
new_date = new_date.astimezone(tz)
new_offset = new_date.utcoffset()
new_date += oldoffset - new_offset
return new_date
def to_string(self) -> str:
if isinstance(self.data, (datetime.datetime, datetime.date)):
return self.data.isoformat()
else:
if self.data.minutes_before is not None:
return 'RELDATE/minutes/{}/{}/'.format( #
self.data.minutes_before,
self.data.base_date_name
)
return 'RELDATE/{}/{}/{}/'.format( #
self.data.days_before,
self.data.time.strftime('%H:%M:%S') if self.data.time else '-',
@@ -99,23 +110,33 @@ class RelativeDateWrapper:
def from_string(cls, input: str):
if input.startswith('RELDATE/'):
parts = input.split('/')
if parts[2] == '-':
time = None
else:
timeparts = parts[2].split(':')
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
try:
data = RelativeDate(
days_before=int(parts[1] or 0),
base_date_name=parts[3],
time=time
)
except ValueError:
if parts[1] == 'minutes':
data = RelativeDate(
days_before=0,
minutes_before=int(parts[2]),
base_date_name=parts[3],
time=time
time=None
)
else:
if parts[2] == '-':
time = None
else:
timeparts = parts[2].split(':')
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
try:
data = RelativeDate(
days_before=int(parts[1] or 0),
base_date_name=parts[3],
time=time,
minutes_before=None
)
except ValueError:
data = RelativeDate(
days_before=0,
base_date_name=parts[3],
time=time,
minutes_before=None
)
if data.base_date_name not in [k[0] for k in BASE_CHOICES]:
raise ValueError('{} is not a valid base date'.format(data.base_date_name))
else:
@@ -138,7 +159,8 @@ class RelativeDateTimeWidget(forms.MultiWidget):
),
forms.NumberInput(),
forms.Select(choices=kwargs.pop('base_choices')),
forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'})
forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'}),
forms.NumberInput(),
)
super().__init__(widgets=widgets, *args, **kwargs)
@@ -146,10 +168,12 @@ class RelativeDateTimeWidget(forms.MultiWidget):
if isinstance(value, str):
value = RelativeDateWrapper.from_string(value)
if not value:
return ['unset', None, 1, 'date_from', None]
return ['unset', None, 1, 'date_from', None, 0]
elif isinstance(value.data, (datetime.datetime, datetime.date)):
return ['absolute', value.data, 1, 'date_from', None]
return ['relative', None, value.data.days_before, value.data.base_date_name, value.data.time]
return ['absolute', value.data, 1, 'date_from', None, 0]
elif value.data.minutes_before is not None:
return ['relative_minutes', None, None, value.data.base_date_name, None, value.data.minutes_before]
return ['relative', None, value.data.days_before, value.data.base_date_name, value.data.time, 0]
def get_context(self, name, value, attrs):
ctx = super().get_context(name, value, attrs)
@@ -162,6 +186,7 @@ class RelativeDateTimeField(forms.MultiValueField):
status_choices = [
('absolute', _('Fixed date:')),
('relative', _('Relative date:')),
('relative_minutes', _('Relative time:')),
]
if kwargs.get('limit_choices'):
limit = kwargs.pop('limit_choices')
@@ -188,6 +213,9 @@ class RelativeDateTimeField(forms.MultiValueField):
forms.TimeField(
required=False,
),
forms.IntegerField(
required=False
),
)
if 'widget' not in kwargs:
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=choices)
@@ -209,11 +237,19 @@ class RelativeDateTimeField(forms.MultiValueField):
return RelativeDateWrapper(data_list[1])
elif data_list[0] == 'unset':
return None
elif data_list[0] == 'relative_minutes':
return RelativeDateWrapper(RelativeDate(
days_before=0,
base_date_name=data_list[3],
time=None,
minutes_before=data_list[5]
))
else:
return RelativeDateWrapper(RelativeDate(
days_before=data_list[2],
base_date_name=data_list[3],
time=data_list[4]
time=data_list[4],
minutes_before=None
))
def clean(self, value):
@@ -221,6 +257,8 @@ class RelativeDateTimeField(forms.MultiValueField):
raise ValidationError(self.error_messages['incomplete'])
elif value[0] == 'relative' and (value[2] is None or not value[3]):
raise ValidationError(self.error_messages['incomplete'])
elif value[0] == 'relative_minutes' and (value[5] is None or not value[3]):
raise ValidationError(self.error_messages['incomplete'])
return super().clean(value)
@@ -292,7 +330,7 @@ class RelativeDateField(RelativeDateTimeField):
return RelativeDateWrapper(RelativeDate(
days_before=data_list[2],
base_date_name=data_list[3],
time=None
time=None, minutes_before=None
))
def clean(self, value):

View File

@@ -45,7 +45,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress()
ia = InvoiceAddress(order=order)
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event)
@@ -81,8 +81,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
logger.exception('Order canceled email could not be sent to attendee')
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,),
acks_late=True)
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
keep_fee_percentage: str, keep_fees: list=None, manual_refund: bool=False,
send: bool=False, send_subject: dict=None, send_message: dict=None,
@@ -146,8 +145,17 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
failed = 0
total = orders_to_cancel.count() + orders_to_change.count()
qs_wl = event.waitinglistentries.filter(subevent=subevent, voucher__isnull=True)
if send_waitinglist:
total += qs_wl.count()
counter = 0
self.update_state(
state='PROGRESS',
meta={'value': 0}
)
for o in orders_to_cancel.only('id', 'total'):
for o in orders_to_cancel.only('id', 'total').iterator():
try:
fee = Decimal('0.00')
fee_sum = Decimal('0.00')
@@ -175,6 +183,13 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
finally:
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
counter += 1
if not self.request.called_directly and counter % max(10, total // 100) == 0:
self.update_state(
state='PROGRESS',
meta={'value': round(counter / total * 100, 2)}
)
except LockTimeoutException:
logger.exception("Could not cancel order")
failed += 1
@@ -182,7 +197,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
logger.exception("Could not cancel order")
failed += 1
for o in orders_to_change.values_list('id', flat=True):
for o in orders_to_change.values_list('id', flat=True).iterator():
with transaction.atomic():
o = event.orders.select_for_update().get(pk=o)
total = Decimal('0.00')
@@ -222,7 +237,21 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
for wle in event.waitinglistentries.filter(subevent=subevent, voucher__isnull=True):
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, subevent)
counter += 1
if not self.request.called_directly and counter % max(10, total // 100) == 0:
self.update_state(
state='PROGRESS',
meta={'value': round(counter / total * 100, 2)}
)
if send_waitinglist:
for wle in qs_wl:
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, subevent)
counter += 1
if not self.request.called_directly and counter % max(10, total // 100) == 0:
self.update_state(
state='PROGRESS',
meta={'value': round(counter / total * 100, 2)}
)
return failed

View File

@@ -6,7 +6,7 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, OuterRef, Q
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
from django.dispatch import receiver
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _, pgettext_lazy
@@ -27,7 +27,7 @@ from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.pricing import get_price
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
from pretix.base.signals import validate_cart_addons
from pretix.base.templatetags.rich_text import rich_text
from pretix.celery_app import app
@@ -96,6 +96,7 @@ error_messages = {
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
'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.'),
'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.'),
@@ -146,6 +147,8 @@ class CartManager:
).select_related('item', 'subevent')
def _is_seated(self, item, subevent):
if not self.event.settings.seating_choice:
return False
if (item, subevent) not in self._seated_cache:
self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists()
return self._seated_cache[item, subevent]
@@ -327,15 +330,18 @@ class CartManager:
raise e
def extend_expired_positions(self):
requires_seat = Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True))
)
)
if not self.event.settings.seating_choice:
requires_seat = Value(0, output_field=IntegerField())
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
).annotate(
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True))
)
)
requires_seat=requires_seat
).prefetch_related(
'item__quotas',
'variation__quotas',
@@ -348,7 +354,7 @@ class CartManager:
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
continue
cp.item.requires_seat = cp.requires_seat
cp.item.requires_seat = self.event.settings.seating_choice and cp.requires_seat
if cp.is_bundled:
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
@@ -605,9 +611,9 @@ class CartManager:
)
# Prepare various containers to hold data later
current_addons = defaultdict(dict) # CartPos -> currently attached add-ons
input_addons = defaultdict(set) # CartPos -> add-ons according to input
selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons
current_addons = defaultdict(lambda: defaultdict(list)) # CartPos -> currently attached add-ons
input_addons = defaultdict(Counter) # CartPos -> final desired set of add-ons
selected_addons = defaultdict(Counter) # CartPos, ItemAddOn -> final desired set of add-ons
cpcache = {} # CartPos.pk -> CartPos
quota_diff = Counter() # Quota -> Number of usages
operations = []
@@ -624,11 +630,9 @@ class CartManager:
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
price_included[cp.pk] = {iao.addon_category_id: iao.price_included for iao in cp.item.addons.all()}
cpcache[cp.pk] = cp
current_addons[cp] = {
(a.item_id, a.variation_id): a
for a in cp.addons.all()
if not a.is_bundled
}
for a in cp.addons.all():
if not a.is_bundled:
current_addons[cp][a.item_id, a.variation_id].append(a)
# Create operations, perform various checks
for a in addons:
@@ -655,25 +659,31 @@ class CartManager:
if not quotas:
raise CartError(error_messages['unavailable'])
# Every item can be attached to very CartPosition at most once
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
if (a['item'], a['variation']) in input_addons[cp.id]:
raise CartError(error_messages['addon_duplicate_item'])
input_addons[cp.id].add((a['item'], a['variation']))
selected_addons[cp.id, item.category_id].add((a['item'], a['variation']))
input_addons[cp.id][a['item'], a['variation']] = a.get('count', 1)
selected_addons[cp.id, item.category_id][a['item'], a['variation']] = a.get('count', 1)
if (a['item'], a['variation']) not in current_addons[cp]:
if price_included[cp.pk].get(item.category_id):
price = TAXED_ZERO
else:
price = self._get_price(item, variation, None, a.get('price'), cp.subevent)
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
for ca in current_addons[cp][a['item'], a['variation']]:
if ca.price != price.gross:
ca.price = price.gross
ca.save(update_fields=['price'])
if a.get('count', 1) > len(current_addons[cp][a['item'], a['variation']]):
# This add-on is new, add it to the cart
for quota in quotas:
quota_diff[quota] += 1
if price_included[cp.pk].get(item.category_id):
price = TAXED_ZERO
else:
price = self._get_price(item, variation, None, None, cp.subevent)
quota_diff[quota] += a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']])
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
count=a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]),
item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
price_before_voucher=None
)
@@ -685,7 +695,10 @@ class CartManager:
item = cp.item
for iao in item.addons.all():
selected = selected_addons[cp.id, iao.addon_category_id]
if len(selected) > iao.max_count:
n_per_i = Counter()
for (i, v), c in selected.items():
n_per_i[i] += c
if sum(selected.values()) > iao.max_count:
# TODO: Proper i18n
# TODO: Proper pluralization
raise CartError(
@@ -696,7 +709,7 @@ class CartManager:
'cat': str(iao.addon_category.name),
}
)
elif len(selected) < iao.min_count:
elif sum(selected.values()) < iao.min_count:
# TODO: Proper i18n
# TODO: Proper pluralization
raise CartError(
@@ -707,28 +720,39 @@ class CartManager:
'cat': str(iao.addon_category.name),
}
)
elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed:
raise CartError(
error_messages['addon_no_multi'],
{
'base': str(item.name),
'cat': str(iao.addon_category.name),
}
)
validate_cart_addons.send(
sender=self.event,
addons={
(self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None)
for s in selected
(self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None): c
for s, c in selected.items() if c > 0
},
base_position=cp,
iao=iao
)
# Detect removed add-ons and create RemoveOperations
for cp, al in current_addons.items():
for cp, al in list(current_addons.items()):
for k, v in al.items():
if k not in input_addons[cp.id]:
if v.expires > self.now_dt:
quotas = list(v.quotas)
input_num = input_addons[cp.id].get(k, 0)
current_num = len(current_addons[cp].get(k, []))
if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_num]:
if a.expires > self.now_dt:
quotas = list(a.quotas)
for quota in quotas:
quota_diff[quota] -= 1
for quota in quotas:
quota_diff[quota] -= 1
op = self.RemoveOperation(position=v)
operations.append(op)
op = self.RemoveOperation(position=a)
operations.append(op)
self._quota_diff.update(quota_diff)
self._operations += operations
@@ -1221,9 +1245,7 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l
@receiver(checkout_confirm_messages, dispatch_uid="cart_confirm_messages")
def confirm_messages(sender, *args, **kwargs):
if not sender.settings.confirm_text:
if not sender.settings.confirm_texts:
return {}
return {
'confirm_text': rich_text(str(sender.settings.confirm_text))
}
confirm_texts = sender.settings.get("confirm_texts", as_type=LazyI18nStringList)
return {'confirm_text_%i' % index: rich_text(str(text)) for index, text in enumerate(confirm_texts)}

View File

@@ -1,5 +1,6 @@
from datetime import timedelta
from django.conf import settings
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
@@ -31,9 +32,9 @@ def clean_cached_files(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
def clean_cached_tickets(sender, **kwargs):
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=3)):
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(hours=settings.CACHE_TICKETS_HOURS)):
cf.delete()
for cf in CachedCombinedTicket.objects.filter(created__lte=now() - timedelta(days=3)):
for cf in CachedCombinedTicket.objects.filter(created__lte=now() - timedelta(hours=settings.CACHE_TICKETS_HOURS)):
cf.delete()
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(minutes=30), file__isnull=True):
cf.delete()

View File

@@ -21,13 +21,20 @@ class ExportError(LazyLocaleException):
pass
@app.task(base=ProfiledEventTask, throws=(ExportError,))
def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
@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):
if not self.request.called_directly:
self.update_state(
state='PROGRESS',
meta={'value': val}
)
file = CachedFile.objects.get(id=fileid)
with language(event.settings.locale), override(event.settings.timezone):
responses = register_data_exporters.send(event)
for receiver, response in responses:
ex = response(event)
ex = response(event, set_progress)
if ex.identifier == provider:
d = ex.render(form_data)
if d is None:
@@ -40,8 +47,15 @@ def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any])
return file.pk
@app.task(base=ProfiledOrganizerUserTask, throws=(ExportError,))
def multiexport(organizer: Organizer, user: User, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
@app.task(base=ProfiledOrganizerUserTask, throws=(ExportError,), bind=True)
def multiexport(self, organizer: Organizer, user: User, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
def set_progress(val):
if not self.request.called_directly:
self.update_state(
state='PROGRESS',
meta={'value': val}
)
file = CachedFile.objects.get(id=fileid)
with language(user.locale), override(user.timezone):
allowed_events = user.get_events_with_permission('can_view_orders')
@@ -52,7 +66,7 @@ def multiexport(organizer: Organizer, user: User, fileid: str, provider: str, fo
for receiver, response in responses:
if not response:
continue
ex = response(events)
ex = response(events, set_progress)
if ex.identifier == provider:
d = ex.render(form_data)
if d is None:

View File

@@ -240,6 +240,14 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation.date = timezone.now().date()
cancellation.payment_provider_text = ''
cancellation.file = None
with language(invoice.locale):
cancellation.invoice_from = invoice.event.settings.get('invoice_address_from')
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
cancellation.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
cancellation.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
cancellation.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
cancellation.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
cancellation.save()
cancellation = build_cancellation(cancellation)

View File

@@ -128,7 +128,10 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
subject = str(subject).format_map(TolerantDict(context))
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) or settings.MAIL_FROM
if event:
sender_name = event.settings.mail_from_name or str(event.name)
sender_name = str(event.name)
if len(sender_name) > 75:
sender_name = sender_name[:75] + "..."
sender_name = event.settings.mail_from_name or sender_name
sender = formataddr((sender_name, sender))
else:
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))

View File

@@ -9,7 +9,9 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db.models import Exists, F, Max, Min, OuterRef, Q, Sum
from django.db.models import (
Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
)
from django.db.models.functions import Coalesce, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
@@ -53,6 +55,7 @@ from pretix.base.signals import (
)
from pretix.celery_app import app
from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval
error_messages = {
'unavailable': _('Some of the products you selected were no longer available. '
@@ -258,7 +261,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
if send_mail:
with language(order.locale):
if order.total == Decimal('0.00'):
email_template = order.event.settings.mail_text_order_free
email_template = order.event.settings.mail_text_order_approved_free
email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code}
else:
email_template = order.event.settings.mail_text_order_approved
@@ -889,13 +892,16 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
except InvoiceAddress.DoesNotExist:
pass
positions = CartPosition.objects.annotate(
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True))
)
requires_seat = Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True))
)
)
if not event.settings.seating_choice:
requires_seat = Value(0, output_field=IntegerField())
positions = CartPosition.objects.annotate(
requires_seat=requires_seat
).filter(
id__in=position_ids, event=event
)
@@ -933,8 +939,9 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
pass
invoice = order.invoices.last() # Might be generated by plugin already
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
if not invoice:
if not invoice and invoice_qualified(order):
if event.settings.get('invoice_generate') == 'True' or (
event.settings.get('invoice_generate') == 'paid' and payment.payment_provider.requires_invoice_immediately):
invoice = generate_invoice(
order,
trigger_pdf=not event.settings.invoice_email_attachment or not order.email
@@ -960,11 +967,12 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
email_attendees = event.settings.mail_send_order_placed_attendee
email_attendees_template = event.settings.mail_text_order_placed_attendee
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry)
if sales_channel in event.settings.mail_sales_channel_placed_paid:
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry)
return order.id
@@ -986,6 +994,7 @@ def expire_orders(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
@minimum_interval(minutes_after_success=60)
def send_expiry_warnings(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0)
days = None
@@ -1056,6 +1065,9 @@ def send_download_reminders(sender, **kwargs):
if days is None:
continue
if o.sales_channel not in event.settings.mail_sales_channel_download_reminder:
continue
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < reminder_date or o.datetime > reminder_date:
continue
@@ -1131,7 +1143,7 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices,
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
)
except SendMailException:
logger.exception('Order changed email could not be sent')
@@ -1370,7 +1382,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['subevent_required'])
seated = item.seat_category_mappings.filter(subevent=subevent).exists()
if seated and not seat:
if seated and not seat and self.event.settings.seating_choice:
raise OrderError(self.error_messages['seat_required'])
elif not seated and seat:
raise OrderError(self.error_messages['seat_forbidden'])
@@ -1869,9 +1881,14 @@ class OrderChangeManager:
def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
if self.reissue_invoice and i and self._invoice_dirty:
self._invoices.append(generate_cancellation(i))
if invoice_qualified(self.order):
if self.reissue_invoice and self._invoice_dirty:
if i:
self._invoices.append(generate_cancellation(i))
if invoice_qualified(self.order) and \
(i or
self.event.settings.invoice_generate == 'True' or (
self.open_payment is not None and self.event.settings.invoice_generate == 'paid' and
self.open_payment.payment_provider.requires_invoice_immediately)):
self._invoices.append(generate_invoice(self.order))
def _check_complete_cancel(self):

View File

@@ -47,8 +47,6 @@ def get_price(item: Item, variation: ItemVariation = None,
eu_reverse_charge=False,
)
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
if force_custom_price and custom_price is not None and custom_price != "":
if custom_price_is_net:
price = tax_rule.tax(custom_price, base_price_is='net', invoice_address=invoice_address,
@@ -56,17 +54,22 @@ def get_price(item: Item, variation: ItemVariation = None,
else:
price = tax_rule.tax(custom_price, base_price_is='gross', invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
if item.free_price and custom_price is not None and custom_price != "":
elif item.free_price and custom_price is not None and custom_price != "":
if not isinstance(custom_price, Decimal):
custom_price = Decimal(str(custom_price).replace(",", "."))
if custom_price > 100000000:
raise ValueError('price_too_high')
price = tax_rule.tax(price, invoice_address=invoice_address)
if custom_price_is_net:
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net',
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross',
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
price.gross = round_decimal(price.gross, item.event.currency)
price.net = round_decimal(price.net, item.event.currency)

View File

@@ -1,9 +1,10 @@
import sys
from collections import Counter, defaultdict
from datetime import timedelta
from itertools import zip_longest
from django.conf import settings
from django.db import models
from django.db import OperationalError, models
from django.db.models import (
Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When,
)
@@ -17,6 +18,7 @@ from pretix.base.models import (
)
from pretix.celery_app import app
from ...helpers.periodic import minimum_interval
from ..signals import periodic_task, quota_availability
@@ -89,7 +91,7 @@ class QuotaAvailability:
def compute(self, now_dt=None):
now_dt = now_dt or now()
quotas = list(self._queue)
quotas = list(set(self._queue))
quotas_original = list(self._queue)
self._queue.clear()
if not quotas:
@@ -103,7 +105,12 @@ class QuotaAvailability:
self.results[q] = resp
self._close(quotas)
self._write_cache(quotas, now_dt)
try:
self._write_cache(quotas, now_dt)
except OperationalError as e:
# Ignore deadlocks when multiple threads try to write to the cache
if 'deadlock' not in str(e).lower():
raise e
def _write_cache(self, quotas, now_dt):
events = {q.event for q in quotas}
@@ -261,15 +268,15 @@ class QuotaAvailability:
qs = self._item_to_quotas[line['item_id']]
for q in qs:
if q.subevent_id == line['subevent_id']:
if line['order__status'] == Order.STATUS_PAID:
self.count_paid_orders[q] += line['c']
q.cached_availability_paid_orders = self.count_paid_orders[q]
elif line['order__status'] == Order.STATUS_PENDING:
self.count_pending_orders[q] += line['c']
if q.release_after_exit and line['is_exited']:
self.count_exited_orders[q] += line['c']
else:
size_left[q] -= line['c']
if line['order__status'] == Order.STATUS_PAID:
self.count_paid_orders[q] += line['c']
q.cached_availability_paid_orders = self.count_paid_orders[q]
elif line['order__status'] == Order.STATUS_PENDING:
self.count_pending_orders[q] += line['c']
if size_left[q] <= 0 and q not in self.results:
if line['order__status'] == Order.STATUS_PAID:
self.results[q] = Quota.AVAILABILITY_GONE, 0
@@ -397,10 +404,18 @@ class QuotaAvailability:
@receiver(signal=periodic_task)
@minimum_interval(minutes_after_success=60)
def build_all_quota_caches(sender, **kwargs):
refresh_quota_caches.apply_async()
def grouper(iterable, n, fillvalue=None):
"""Collect data into fixed-length chunks or blocks"""
# grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
args = [iter(iterable)] * n
return zip_longest(fillvalue=fillvalue, *args)
@app.task
@scopes_disabled()
def refresh_quota_caches():
@@ -424,5 +439,8 @@ def refresh_quota_caches():
Q(subevent__date_to__isnull=False, subevent__date_to__gte=now() - timedelta(days=14)) |
Q(subevent__date_from__gte=now() - timedelta(days=14))
)
for q in quotas:
q.availability()
for qs in grouper(quotas, 100, None):
qa = QuotaAvailability(early_out=False)
qa.queue(*[q for q in qs if q is not None])
qa.compute()

View File

@@ -60,7 +60,6 @@ def generate_seats(event, subevent, plan, mapping):
seat = current_seats.pop(ss.guid)
updated = any([
update(seat, 'product', p),
update(seat, 'name', ss.name),
update(seat, 'row_name', ss.row),
update(seat, 'seat_number', ss.number),
update(seat, 'zone_name', ss.zone),
@@ -77,7 +76,6 @@ def generate_seats(event, subevent, plan, mapping):
event=event,
subevent=subevent,
seat_guid=ss.guid,
name=ss.name,
row_name=ss.row,
seat_number=ss.number,
zone_name=ss.zone,

View File

@@ -1,5 +1,6 @@
import json
from collections import OrderedDict
import operator
from collections import OrderedDict, UserList
from datetime import datetime
from decimal import Decimal
from typing import Any
@@ -37,6 +38,20 @@ def country_choice_kwargs():
}
class LazyI18nStringList(UserList):
def __init__(self, init_list=None):
super().__init__()
if init_list is not None:
self.data = [v if isinstance(v, LazyI18nString) else LazyI18nString(v) for v in init_list]
def serialize(self):
return json.dumps([s.data for s in self.data])
@classmethod
def unserialize(cls, s):
return cls(json.loads(s))
DEFAULTS = {
'max_items_per_order': {
'default': '10',
@@ -309,6 +324,16 @@ DEFAULTS = {
help_text=_("The expiration date will not be shown if the invoice is generated after the order is paid."),
)
},
'invoice_numbers_counter_length': {
'default': '5',
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'form_kwargs': dict(
label=_("Minimum length of invoice number after prefix"),
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
)
},
'invoice_numbers_consecutive': {
'default': 'True',
'type': bool,
@@ -500,7 +525,7 @@ DEFAULTS = {
('admin', _('Only manually in admin panel')),
('user', _('Automatically on user request')),
('True', _('Automatically for all created orders')),
('paid', _('Automatically on payment')),
('paid', _('Automatically on payment or when required by payment method')),
),
),
'form_kwargs': dict(
@@ -511,7 +536,7 @@ DEFAULTS = {
('admin', _('Only manually in admin panel')),
('user', _('Automatically on user request')),
('True', _('Automatically for all created orders')),
('paid', _('Automatically on payment')),
('paid', _('Automatically on payment or when required by payment method')),
),
help_text=_("Invoices will never be automatically generated for free orders.")
)
@@ -910,7 +935,8 @@ DEFAULTS = {
('list', _('List')),
('week', _('Week calendar')),
('calendar', _('Month calendar')),
)
),
help_text=_('If your event series has more than 100 dates, only the month or week calendar can be used.')
),
},
'last_order_modification_date': {
@@ -925,6 +951,48 @@ DEFAULTS = {
"multiple event dates, the earliest date will be used."),
)
},
'change_allow_user_variation': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Customers can change the variation of the products they purchased"),
)
},
'change_allow_user_price': {
'default': 'gte',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': dict(
choices=(
('gte', _('Only allow changes if the resulting price is higher or equal than the previous price.')),
('gt', _('Only allow changes if the resulting price is higher than the previous price.')),
('eq', _('Only allow changes if the resulting price is equal to the previous price.')),
('any', _('Allow changes regardless of price, even if this results in a refund.')),
)
),
'form_kwargs': dict(
label=_("Requirement for changed prices"),
choices=(
('gte', _('Only allow changes if the resulting price is higher or equal than the previous price.')),
('gt', _('Only allow changes if the resulting price is higher than the previous price.')),
('eq', _('Only allow changes if the resulting price is equal to the previous price.')),
('any', _('Allow changes regardless of price, even if this results in a refund.')),
),
widget=forms.RadioSelect,
),
},
'change_allow_user_until': {
'default': None,
'type': RelativeDateWrapper,
'form_class': RelativeDateTimeField,
'serializer_class': SerializerRelativeDateTimeField,
'form_kwargs': dict(
label=_("Do not allow changes after"),
)
},
'cancel_allow_user': {
'default': 'True',
'type': bool,
@@ -1077,18 +1145,11 @@ DEFAULTS = {
),
'serializer_class': serializers.URLField,
},
'confirm_text': {
'default': None,
'type': LazyI18nString,
'form_class': I18nFormField,
'serializer_class': I18nField,
'form_kwargs': dict(
label=_('Confirmation text'),
help_text=_('This text needs to be confirmed by the user before a purchase is possible. You could for example '
'link your terms of service here. If you use the Pages feature to publish your terms of service, '
'you don\'t need this setting since you can configure it there.'),
widget=I18nTextarea,
)
'confirm_texts': {
'default': LazyI18nStringList(),
'type': LazyI18nStringList,
'serializer_class': serializers.ListField,
'serializer_kwargs': lambda: dict(child=I18nField()),
},
'mail_html_renderer': {
'default': 'classic',
@@ -1140,6 +1201,14 @@ DEFAULTS = {
"Defaults to your event name."),
)
},
'mail_sales_channel_placed_paid': {
'default': ['web'],
'type': list,
},
'mail_sales_channel_download_reminder': {
'default': ['web'],
'type': list,
},
'mail_text_signature': {
'type': LazyI18nString,
'default': ""
@@ -1351,6 +1420,19 @@ You can select a payment method and perform the payment here:
{url}
Best regards,
Your {event} team"""))
},
'mail_text_order_approved_free': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
we approved your order for {event} and will be happy to welcome you
at our event. As you only ordered free products, no payment is required.
You can change your order details and view the status of your order at
{url}
Best regards,
Your {event} team"""))
},
@@ -1662,6 +1744,18 @@ Your {event} team"""))
'default': settings.ENTROPY['giftcard_secret'],
'type': int
},
'seating_choice': {
'default': 'True',
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Customers can choose their own seats"),
help_text=_("If disabled, you will need to manually assign seats in the backend. Note that this can mean "
"people will not know their seat after their purchase and it might not be written on their "
"ticket."),
),
'type': bool,
},
'seating_minimal_distance': {
'default': '0',
'type': float
@@ -1881,6 +1975,9 @@ def i18n_uns(v):
settings_hierarkey.add_type(LazyI18nString,
serialize=lambda s: json.dumps(s.data),
unserialize=i18n_uns)
settings_hierarkey.add_type(LazyI18nStringList,
serialize=operator.methodcaller("serialize"),
unserialize=LazyI18nStringList.unserialize)
settings_hierarkey.add_type(RelativeDateWrapper,
serialize=lambda rdw: rdw.to_string(),
unserialize=lambda s: RelativeDateWrapper.from_string(s))

View File

@@ -309,7 +309,7 @@ validate_cart_addons = EventPluginSignal(
"""
This signal is sent when a user tries to select a combination of addons. In contrast to
``validate_cart``, this is executed before the cart is actually modified. You are passed
an argument ``addons`` containing a set of ``(item, variation or None)`` tuples as well
an argument ``addons`` containing a dict of ``(item, variation or None) → count`` tuples as well
as the ``ItemAddOn`` object as the argument ``iao`` and the base cart position as
``base_position``.
The response of receivers will be ignored, but you can raise a CartError with an

View File

@@ -10,6 +10,10 @@
</label>
{% if selopt.value == "absolute" %}
{% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
{% elif selopt.value == "relative_minutes" %}
{% include widget.subwidgets.5.template_name with widget=widget.subwidgets.5 %}
{% trans "minutes before" %}
{% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
{% elif selopt.value == "relative" %}
{% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
{% trans "days before" %}

View File

@@ -37,7 +37,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
arg,
floatformat(value, 2)
)
return format_currency(value, arg, locale=translation.get_language())
return format_currency(value, arg, locale=translation.get_language()[:2])
except:
return '{} {}'.format(
arg,

View File

@@ -53,6 +53,7 @@ class BaseQuestionsViewMixin:
data=(self.request.POST if self.request.method == 'POST' else None),
files=(self.request.FILES if self.request.method == 'POST' else None))
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())
if len(form.fields) > 0:
formlist.append(form)
return formlist
@@ -61,7 +62,7 @@ class BaseQuestionsViewMixin:
def formdict(self):
storage = OrderedDict()
for f in self.forms:
pos = f.cartpos or f.orderpos
pos = f.pos
if pos.addon_to_id:
if pos.addon_to not in storage:
storage[pos.addon_to] = []

View File

@@ -77,7 +77,8 @@ class AsyncAction:
data = self._ajax_response_data()
data.update({
'async_id': res.id,
'ready': ready
'ready': ready,
'started': False,
})
if ready:
if res.successful() and not isinstance(res.info, Exception):
@@ -100,6 +101,15 @@ class AsyncAction:
'success': False,
'message': str(self.get_error_message(res.info))
})
elif res.state == 'PROGRESS':
data.update({
'started': True,
'percentage': res.result.get('value', 0)
})
elif res.state == 'STARTED':
data.update({
'started': True,
})
return data
def get_result(self, request):

View File

@@ -10,6 +10,7 @@ from django.forms.utils import from_current_timezone
from django.utils.translation import gettext_lazy as _
from ...base.forms import I18nModelForm
# Import for backwards compatibility with okd import paths
from ...base.forms.widgets import ( # noqa
DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget,

View File

@@ -518,7 +518,6 @@ class EventSettingsForm(SettingsForm):
'attendee_company_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'confirm_text',
'banner_text',
'banner_text_bottom',
'order_email_asked_twice',
@@ -535,11 +534,6 @@ class EventSettingsForm(SettingsForm):
def __init__(self, *args, **kwargs):
self.event = kwargs['obj']
super().__init__(*args, **kwargs)
self.fields['confirm_text'].widget.attrs['rows'] = '3'
self.fields['confirm_text'].widget.attrs['placeholder'] = _(
'e.g. I hereby confirm that I have read and agree with the event organizer\'s terms of service '
'and agree with them.'
)
self.fields['name_scheme'].choices = (
(k, _('Ask for {fields}, display like {example}').format(
fields=' + '.join(str(vv[1]) for vv in v['fields']),
@@ -575,6 +569,9 @@ class CancelSettingsForm(SettingsForm):
'cancel_allow_user_paid_adjust_fees_explanation',
'cancel_allow_user_paid_refund_as_giftcard',
'cancel_allow_user_paid_require_approval',
'change_allow_user_variation',
'change_allow_user_price',
'change_allow_user_until',
]
def __init__(self, *args, **kwargs):
@@ -674,6 +671,7 @@ class InvoiceSettingsForm(SettingsForm):
'invoice_numbers_consecutive',
'invoice_numbers_prefix',
'invoice_numbers_prefix_cancellations',
'invoice_numbers_counter_length',
'invoice_address_explanation_text',
'invoice_email_attachment',
'invoice_address_from_name',
@@ -746,6 +744,11 @@ def multimail_validate(val):
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."))
class MailSettingsForm(SettingsForm):
auto_fields = [
'mail_prefix',
@@ -754,6 +757,27 @@ class MailSettingsForm(SettingsForm):
'mail_attach_ical',
]
mail_sales_channel_placed_paid = forms.MultipleChoiceField(
choices=lambda: [(ident, sc.verbose_name) for ident, sc in get_all_sales_channels().items()],
label=_('Sales channels for checkout emails'),
help_text=_('The order placed and paid emails will only be send to orders from these sales channels. '
'The online shop must be enabled.'),
widget=forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
validators=[contains_web_channel_validate],
)
mail_sales_channel_download_reminder = forms.MultipleChoiceField(
choices=lambda: [(ident, sc.verbose_name) for ident, sc in get_all_sales_channels().items()],
label=_('Sales channels'),
help_text=_('This email will only be send to orders from these sales channels. The online shop must be enabled.'),
widget=forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
validators=[contains_web_channel_validate],
)
mail_bcc = forms.CharField(
label=_("Bcc address"),
help_text=_("All emails will be sent to this address as a Bcc copy"),
@@ -905,6 +929,13 @@ class MailSettingsForm(SettingsForm):
required=False,
widget=I18nTextarea,
help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order "
"template from below instead."),
)
mail_text_order_approved_free = I18nFormField(
label=_("Approved free order"),
required=False,
widget=I18nTextarea,
help_text=_("This will only be sent out for free orders. Non-free orders will receive the non-free order "
"template from above instead."),
)
mail_text_order_denied = I18nFormField(
@@ -954,6 +985,7 @@ class MailSettingsForm(SettingsForm):
'mail_text_order_placed_attendee': ['event', 'order', 'position'],
'mail_text_order_placed_require_approval': ['event', 'order'],
'mail_text_order_approved': ['event', 'order'],
'mail_text_order_approved_free': ['event', 'order'],
'mail_text_order_denied': ['event', 'order', 'comment'],
'mail_text_order_paid': ['event', 'order', 'payment_info'],
'mail_text_order_paid_attendee': ['event', 'order', 'position'],
@@ -1312,3 +1344,25 @@ class ItemMetaPropertyForm(forms.ModelForm):
widgets = {
'default': forms.TextInput()
}
class ConfirmTextForm(I18nForm):
text = I18nFormField(
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
)
class BaseConfirmTextFormSet(I18nFormSetMixin, forms.BaseFormSet):
def __init__(self, *args, **kwargs):
event = kwargs.pop('event', None)
if event:
kwargs['locales'] = event.settings.get('locales')
super().__init__(*args, **kwargs)
ConfirmTextFormset = formset_factory(
ConfirmTextForm,
formset=BaseConfirmTextFormSet,
can_order=True, can_delete=True, extra=0
)

View File

@@ -12,8 +12,8 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
Checkin, Event, EventMetaProperty, EventMetaValue, Invoice, Item, Order,
OrderPayment, OrderPosition, OrderRefund, Organizer, Question,
Checkin, Event, EventMetaProperty, EventMetaValue, Invoice, InvoiceAddress,
Item, Order, OrderPayment, OrderPosition, OrderRefund, Organizer, Question,
QuestionAnswer, SubEvent,
)
from pretix.base.signals import register_payment_providers
@@ -139,26 +139,34 @@ class OrderFilterForm(FilterForm):
| Q(invoice_no__iexact=u.zfill(5))
| Q(full_invoice_no__iexact=u)
).values_list('order_id', flat=True)
matching_positions = OrderPosition.objects.filter(
Q(order=OuterRef('pk')) & Q(
Q(
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
| Q(secret__istartswith=u)
| Q(pseudonymization_id__istartswith=u)
)
).values('id')
mainq = (
).values_list('order_id', flat=True)
matching_invoice_addresses = InvoiceAddress.objects.filter(
Q(
Q(name_cached__icontains=u) | Q(company__icontains=u)
)
).values_list('order_id', flat=True)
matching_orders = Order.objects.filter(
code
| Q(email__icontains=u)
| Q(invoice_address__name_cached__icontains=u)
| Q(invoice_address__company__icontains=u)
| Q(pk__in=matching_invoices)
| Q(comment__icontains=u)
| Q(has_pos=True)
).values_list('id', flat=True)
mainq = (
Q(pk__in=matching_orders)
| Q(pk__in=matching_invoices)
| Q(pk__in=matching_positions)
| Q(pk__in=matching_invoice_addresses)
| Q(pk__in=matching_invoices)
)
for recv, q in order_search_filter_q.send(sender=getattr(self, 'event', None), query=u):
mainq = mainq | q
qs = qs.annotate(has_pos=Exists(matching_positions)).filter(
qs = qs.filter(
mainq
)
@@ -837,6 +845,7 @@ class CheckInFilterForm(FilterForm):
label=_('Check-in status'),
choices=(
('', _('All attendees')),
('3', pgettext_lazy('checkin state', 'Checked in but left')),
('2', pgettext_lazy('checkin state', 'Present')),
('1', _('Checked in')),
('0', _('Not checked in')),
@@ -867,6 +876,7 @@ class CheckInFilterForm(FilterForm):
qs = qs.filter(
Q(order__code__istartswith=u)
| Q(secret__istartswith=u)
| Q(pseudonymization_id__istartswith=u)
| Q(order__email__icontains=u)
| Q(attendee_name_cached__icontains=u)
| Q(attendee_email__icontains=u)
@@ -883,6 +893,10 @@ class CheckInFilterForm(FilterForm):
qs = qs.filter(last_entry__isnull=False).filter(
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
)
elif s == '3':
qs = qs.filter(last_entry__isnull=False).filter(
Q(last_exit__isnull=False) & Q(last_exit__gte=F('last_entry'))
)
elif s == '0':
qs = qs.filter(last_entry__isnull=True)

View File

@@ -437,6 +437,7 @@ class ItemUpdateForm(I18nModelForm):
self.fields['description'].widget.attrs['rows'] = '4'
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=_('Sales channels'),
required=False,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
@@ -661,7 +662,8 @@ class ItemAddOnForm(I18nModelForm):
'addon_category',
'min_count',
'max_count',
'price_included'
'price_included',
'multi_allowed',
]
help_texts = {
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '

View File

@@ -399,7 +399,10 @@ class OrderPositionChangeForm(forms.Form):
self.fields['tax_rule'].queryset = instance.event.tax_rules.all()
self.fields['tax_rule'].label_from_instance = self.taxrule_label_from_instance
if not instance.seat:
if not instance.seat and not (
not instance.event.settings.seating_choice and
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
):
del self.fields['seat']
choices = [

View File

@@ -162,6 +162,49 @@ def _display_checkin(event, logentry):
else:
checkin_list = _("(unknown)")
if logentry.action_type == 'pretix.event.checkin.unknown':
if show_dt:
return _(
'Unknown scan of code "{barcode}" at {datetime} for list "{list}", type "{type}".'
).format(
posid=data.get('positionid'),
type=data.get('type'),
barcode=data.get('barcode'),
datetime=dt_formatted,
list=checkin_list
)
else:
return _(
'Unknown scan of code "{barcode}" for list "{list}", type "{type}".'
).format(
posid=data.get('positionid'),
type=data.get('type'),
barcode=data.get('barcode'),
list=checkin_list
)
if logentry.action_type == 'pretix.event.checkin.denied':
if show_dt:
return _(
'Denied scan of position #{posid} at {datetime} for list "{list}", type "{type}", '
'error code "{errorcode}".'
).format(
posid=data.get('positionid'),
type=data.get('type'),
errorcode=data.get('errorcode'),
datetime=dt_formatted,
list=checkin_list
)
else:
return _(
'Denied scan of position #{posid} for list "{list}", type "{type}", error code "{errorcode}".'
).format(
posid=data.get('positionid'),
type=data.get('type'),
errorcode=data.get('errorcode'),
list=checkin_list
)
if data.get('type') == Checkin.TYPE_EXIT:
if show_dt:
return _('Position #{posid} has been checked out at {datetime} for list "{list}".').format(
@@ -397,7 +440,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
bleach.clean(logentry.parsed_data.get('msg'), tags=[], strip=True)
)
if logentry.action_type == 'pretix.event.checkin':
if logentry.action_type.startswith('pretix.event.checkin'):
return _display_checkin(sender, logentry)
if logentry.action_type == 'pretix.control.views.checkin':

View File

@@ -92,6 +92,17 @@ This is no ``EventPluginSignal``, so you do not get the event in the ``sender``
and you may get the signal regardless of whether your plugin is active.
"""
event_dashboard_top = EventPluginSignal(
providing_args=['request']
)
"""
This signal is sent out to include custom HTML in the top part of the the event dashboard.
Receivers should return HTML.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
event_dashboard_widgets = EventPluginSignal(
providing_args=[]
)

View File

@@ -431,6 +431,10 @@
<h3></h3>
<p class="text"></p>
<p class="status">{% trans "If this takes longer than a few minutes, please contact us." %}</p>
<div class="progress">
<div class="progress-bar progress-bar-success">
</div>
</div>
</div>
</div>
</div>

View File

@@ -127,6 +127,10 @@
<td class="text-right flip">
<a href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-default btn-sm"><i class="fa fa-eye"></i></a>
{% if "can_change_event_settings" in request.eventpermset %}
<a href="{% url "control:event.orders.checkinlists.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ cl.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.orders.checkinlists.edit" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
<a href="{% url "control:event.orders.checkinlists.delete" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}

View File

@@ -38,6 +38,17 @@
</div>
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Order changes" %}</legend>
<div class="alert alert-info">
{% blocktrans trimmed %}
Allowing users to change their order is a feature under development. Therefore, currently only specific changes (such as changing the variation of a product) are possible. More options might be added later.
{% endblocktrans %}
</div>
{% bootstrap_field form.change_allow_user_variation layout="control" %}
{% bootstrap_field form.change_allow_user_price layout="control" %}
{% bootstrap_field form.change_allow_user_until layout="control" %}
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">

View File

@@ -3,6 +3,7 @@
{% load eventurl %}
{% load bootstrap3 %}
{% load static %}
{% load eventsignal %}
{% block title %}{{ request.event.name }}{% endblock %}
{% block content %}
<h1>
@@ -63,6 +64,7 @@
class="btn btn-primary">{% trans "Show affected orders" %}</a>
</div>
{% endif %}
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
{% if actions|length > 0 %}
<div class="panel panel-danger">
<div class="panel-heading">

View File

@@ -19,6 +19,7 @@
{% bootstrap_field form.invoice_numbers_consecutive layout="control" %}
{% bootstrap_field form.invoice_numbers_prefix layout="control" %}
{% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %}
{% bootstrap_field form.invoice_numbers_counter_length layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Address form" %}</legend>

View File

@@ -24,6 +24,14 @@
</option>
{% endif %}
{% endfor %}
{% for d in devicelist %}
{% if d.device__id %}
<option value="d-{{ d.device__id }}"
{% if "d-" in request.GET.user and request.GET.user|slice:"2:" == d.device__id|slugify %}selected="selected"{% endif %}>
{{ d.device__name }}
</option>
{% endif %}
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
</p>

View File

@@ -17,6 +17,7 @@
{% bootstrap_field form.mail_text_signature layout="control" %}
{% bootstrap_field form.mail_bcc layout="control" %}
{% bootstrap_field form.mail_attach_ical layout="control" %}
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "E-mail design" %}</legend>
@@ -70,10 +71,10 @@
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_text_download_reminder_attendee" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_denied" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_approved_free,mail_text_order_denied" %}
</div>
</fieldset>
<fieldset>

View File

@@ -3,7 +3,7 @@
{% load bootstrap3 %}
{% block inside %}
<h1>{% trans "Payment settings" %}</h1>
<form action="" method="post" class="form-horizontal form-plugins">
<form action="" method="post" class="form-horizontal form-plugins" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>

View File

@@ -26,7 +26,11 @@
{% bootstrap_field form.date_to layout="control" %}
<div class="geodata-section">
{% bootstrap_field form.location layout="control" %}
<div class="form-group geodata-group" data-tiles="{{ global_settings.leaflet_tiles|default_if_none:"" }}" data-attrib="{{ global_settings.leaflet_tiles_attribution }}" data-icon="{% static "leaflet/images/marker-icon.png" %}" data-shadow="{% static "leaflet/images/marker-shadow.png" %}">
<div class="form-group geodata-group"
data-tiles="{{ global_settings.leaflet_tiles|default_if_none:"" }}"
data-attrib="{{ global_settings.leaflet_tiles_attribution }}"
data-icon="{% static "leaflet/images/marker-icon.png" %}"
data-shadow="{% static "leaflet/images/marker-shadow.png" %}">
<label class="col-md-3 control-label">
{% trans "Geo coordinates" %}<br>
<span class="optional">{% trans "Optional" %}</span>
@@ -99,7 +103,77 @@
{% bootstrap_field sform.frontpage_text layout="control" %}
{% bootstrap_field sform.presale_has_ended_text layout="control" %}
{% bootstrap_field sform.voucher_explanation_text layout="control" %}
{% bootstrap_field sform.confirm_text layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Confirmation text" %}<br>
<span class="optional">{% trans "Optional" %}</span>
</label>
<div class="col-md-9">
<div class="help-block">
{% blocktrans trimmed %}
These texts need to be confirmed by the user before a purchase is possible. You could
for example link your terms of service here. If you use the Pages feature to publish
your terms of service, you don't need this setting since you can configure it there.
{% endblocktrans %}
</div>
<div class="formset" data-formset data-formset-prefix="{{ confirm_texts_formset.prefix }}">
{{ confirm_texts_formset.management_form }}
{% bootstrap_formset_errors confirm_texts_formset %}
<div data-formset-body>
{% for form in confirm_texts_formset %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-md-10">
{% bootstrap_form_errors form %}
{% bootstrap_field form.text layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ confirm_texts_formset.empty_form.id }}
{% bootstrap_field confirm_texts_formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field confirm_texts_formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-md-10">
{% bootstrap_field confirm_texts_formset.empty_form.text layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add confirmation text" %}</button>
</p>
</div>
</div>
</div>
{% bootstrap_field sform.checkout_email_helptext layout="control" %}
{% bootstrap_field sform.banner_text layout="control" %}
{% bootstrap_field sform.banner_text_bottom layout="control" %}
@@ -161,17 +235,18 @@
<legend>{% trans "Item metadata" %}</legend>
<p>
{% blocktrans trimmed %}
You can here define a set of metadata properties (i.e. variables) that you can later set for your
items and re-use in places like ticket layouts. This is an useful timesaver if you create lots and
lots of items.
You can here define a set of metadata properties (i.e. variables) that you can later set for
your items and re-use in places like ticket layouts. This is an useful timesaver if you create
lots and lots of items.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div class="formset" data-formset
data-formset-prefix="{{ item_meta_property_formset.prefix }}">
{{ item_meta_property_formset.management_form }}
{% bootstrap_formset_errors item_meta_property_formset %}
<div data-formset-body>
{% for form in formset %}
<div class="row" data-formset-form>
{% for form in item_meta_property_formset %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
@@ -192,16 +267,16 @@
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row" data-formset-form>
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{{ item_meta_property_formset.empty_form.id }}
{% bootstrap_field item_meta_property_formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-5">
{% bootstrap_field formset.empty_form.name layout='inline' form_group_class="" %}
{% bootstrap_field item_meta_property_formset.empty_form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-5 col-lg-6">
{% bootstrap_field formset.empty_form.default layout='inline' form_group_class="" %}
{% bootstrap_field item_meta_property_formset.empty_form.default layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 col-lg-1 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
@@ -223,12 +298,12 @@
</button>
<div class="pull-left">
<a href="{% url "control:event.dangerzone" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-lg">
class="btn btn-danger btn-lg">
<span class="fa fa-trash"></span>
{% trans "Cancel or delete event" %}
</a>
<a href="{% url "control:events.add" %}?clone={{ request.event.pk }}"
class="btn btn-default btn-lg">
class="btn btn-default btn-lg">
<span class="fa fa-copy"></span>
{% trans "Clone event" %}
</a>

View File

@@ -8,8 +8,7 @@
workshops as add-ons to the conference ticket. With this configuration, the workshops cannot be bought
on their own but only in combination with a conference ticket. You can here specify categories of products
that can be used as add-ons to this product. You can also specify the minimum and maximum number of
add-ons of the given category that can or need to be chosen. The user can buy every add-on from the
category at most once. If an add-on product has multiple variations, only one of them can be bought.
add-ons of the given category that can or need to be chosen.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
@@ -43,6 +42,7 @@
{% bootstrap_field form.addon_category layout="control" %}
{% bootstrap_field form.min_count layout="control" %}
{% bootstrap_field form.max_count layout="control" %}
{% bootstrap_field form.multi_allowed layout="control" %}
{% bootstrap_field form.price_included layout="control" %}
</div>
</div>
@@ -75,6 +75,7 @@
{% bootstrap_field formset.empty_form.addon_category layout="control" %}
{% bootstrap_field formset.empty_form.min_count layout="control" %}
{% bootstrap_field formset.empty_form.max_count layout="control" %}
{% bootstrap_field formset.empty_form.multi_allowed layout="control" %}
{% bootstrap_field formset.empty_form.price_included layout="control" %}
</div>
</div>

View File

@@ -112,7 +112,7 @@
</div>
{% endif %}
{% if position.seat %}
{% if position.form.seat %}
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Seat" %}</strong>

View File

@@ -0,0 +1,55 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Device logs" %}{% endblock %}
{% block inside %}
<h1>{% trans "Device logs" %}</h1>
<h2>
<span class="fa fa-mobile fa-fw"></span> {{ device }}
</h2>
<ul class="list-group">
{% for log in logs %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="fa fa-clock-o"></span>
{{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if log.shredded %}
<span class="fa fa-eraser fa-danger fa-fw"
data-toggle="tooltip"
title="{% trans "Personal data was cleared from this log entry." %}">
</span>
{% endif %}
</div>
<div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.event %}
<span class="fa fa-calendar fa-fw"></span>
<a href="{% url "control:event.index" organizer=request.organizer.slug event=log.event.slug %}">
{{ log.event.name }}
</a>
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">
{% if log.display_object %}
<span class="fa fa-flag"></span> {{ log.display_object|safe }}
{% endif %}
</div>
<div class="col-lg-6 col-sm-12 col-xs-12">
{{ log.display }}
{% if staff_session %}
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>
{% endif %}
</div>
</div>
</li>
{% empty %}
<div class="list-group-item">
<em>{% trans "No results" %}</em>
</div>
{% endfor %}
</ul>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

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