Compare commits

...

356 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
Raphael Michel
e4e91523a0 Bump to 3.10.0 2020-07-17 12:56:11 +02:00
Raphael Michel
00827700de Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-07-17 09:50:28 +02:00
Raphael Michel
0a87225a9a Add missing fields to API 2020-07-17 09:31:25 +02:00
Raphael Michel
9371d221bf Merge pull request #1720 from pretix-translations/weblate-pretix-pretix
Translations update from Weblate
2020-07-17 08:56:50 +02:00
Dennis Lichtenthäler
08a3c846b6 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3700 of 3700 strings)

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

powered by weblate
2020-07-17 00:00:15 +02:00
Raphael Michel
1c84de9ab2 E-Mail: Do not use .prettify(), it does not preserve the original whitespsace 2020-07-16 17:45:17 +02:00
Raphael Michel
980f4012bc Make sure correct language is active when generating email attachments 2020-07-16 12:14:43 +02:00
Raphael Michel
591d70eabe Merge pull request #1718 from pretix-translations/weblate-pretix-pretix
Co-authored-by: Raphael Michel <michel@rami.io>
2020-07-16 09:03:15 +02:00
Raphael Michel
2c4609604d Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3700 of 3700 strings)

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

powered by weblate
2020-07-16 09:00:34 +02:00
Raphael Michel
30c2b8b03f Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3700 of 3700 strings)

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

powered by weblate
2020-07-16 09:00:33 +02:00
Raphael Michel
a685af6433 Clone button for products 2020-07-16 08:53:18 +02:00
Raphael Michel
f179a220bc Widget: Properly escape voucher codes 2020-07-16 08:42:40 +02:00
Raphael Michel
b61893e3b1 Do not even import excluded plugins 2020-07-16 08:42:29 +02:00
Raphael Michel
d3282a1acb Fix OrderChangeManager.change_price() for items without tax rule 2020-07-15 09:14:58 +02:00
Raphael Michel
c585946e72 Drop "Presale ::" from event page title 2020-07-14 16:53:26 +02:00
Raphael Michel
b6245b97ca Validate max length of attendee address 2020-07-14 16:26:46 +02:00
Raphael Michel
51720c3afe Fix irregular behaviour on second use of widget 2020-07-14 09:15:07 +02:00
Andreas Teuber
4746b8e456 Ask only for VAT ID if company is inside EU (#1709)
Co-authored-by: Andreas Teuber <andreas.teuber@passiv.de>
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2020-07-13 18:04:09 +02:00
Raphael Michel
b2401f7641 Fix grammar error in German translation 2020-07-13 15:57:33 +02:00
Raphael Michel
b4cd11ef94 Fix creation of tax rules with custom rules 2020-07-13 15:57:33 +02:00
Raphael Michel
33682e1b38 Fix incorrect preview/history of subject rendering 2020-07-13 15:57:33 +02:00
Raphael Michel
e10e3300ba Fix ineffective translation of string 2020-07-12 11:01:15 +02:00
Raphael Michel
4f0eadfd6e Fix language file 2020-07-09 15:18:16 +02:00
Raphael Michel
0f9ec2ce7d Fix ZeroDivisionError in question statistics 2020-07-09 14:56:46 +02:00
Raphael Michel
93e3cf1d99 Update locales 2020-07-09 14:53:16 +02:00
Raphael Michel
3affaa8c85 Show local time if browser timezone is different 2020-07-09 14:51:16 +02:00
Raphael Michel
fddf134755 Update spelling wordlist 2020-07-09 10:55:34 +02:00
Raphael Michel
568398e4e7 Merge pull request #1715 from pretix-translations/weblate-pretix-pretix 2020-07-09 10:51:46 +02:00
Raphael Michel
fc08531639 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3700 of 3700 strings)

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

powered by weblate
2020-07-09 10:51:25 +02:00
Raphael Michel
bd25ce238d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3700 of 3700 strings)

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

powered by weblate
2020-07-09 10:51:24 +02:00
Raphael Michel
c88ce8a9a8 Translated on translate.pretix.eu (German)
Currently translated at 99.4% (3673 of 3696 strings)

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

powered by weblate
2020-07-09 10:32:34 +02:00
Raphael Michel
004403e2c8 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-07-09 10:32:20 +02:00
Raphael Michel
0cde5288ac Fix trailing whitespace 2020-07-09 10:31:46 +02:00
Raphael Michel
e585da2901 Merge pull request #1704 from pretix-translations/weblate-pretix-pretix
Co-authored-by: Frank <webappconcept@gmail.com>
Co-authored-by: Dennis Lichtenthäler <lichtenthaeler@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: oocf <oswaldocerna@gmail.com>
Co-authored-by: Maarten van den Berg <maartenberg1@gmail.com>
2020-07-09 10:31:23 +02:00
Raphael Michel
f369fca091 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-07-09 10:20:39 +02:00
Raphael Michel
e0e638ac8c Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-07-09 10:20:39 +02:00
Dennis Lichtenthäler
4abe906511 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-07-09 10:20:38 +02:00
Maarten van den Berg
855a47776f Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-07-09 10:20:24 +02:00
Maarten van den Berg
c63c499a95 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 99.8% (3676 of 3684 strings)

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

powered by weblate
2020-07-09 10:20:23 +02:00
Maarten van den Berg
f06a23ae95 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-07-09 10:20:09 +02:00
oocf
2b68a22aad Translated on translate.pretix.eu (Spanish)
Currently translated at 83.0% (3058 of 3684 strings)

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

powered by weblate
2020-07-09 10:19:56 +02:00
Raphael Michel
86d42cc524 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-07-09 10:19:36 +02:00
Dennis Lichtenthäler
6e8040ac9d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (125 of 125 strings)

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

powered by weblate
2020-07-09 10:19:35 +02:00
Dennis Lichtenthäler
c742c9979b Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-07-09 10:19:19 +02:00
Frank
15f3880fcd Translated on translate.pretix.eu (Italian)
Currently translated at 17.2% (633 of 3684 strings)

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

powered by weblate
2020-07-09 10:18:50 +02:00
Raphael Michel
6bf5d8cb5e Revert "Update po files"
This reverts commit 27d772f52f.
2020-07-09 10:07:18 +02:00
Raphael Michel
27d772f52f Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-07-09 09:58:56 +02:00
Raphael Michel
6e9d921af6 Allow country specific tax rules (#1714) 2020-07-08 15:00:13 +02:00
Martin Gross
1c9a1b5e02 Mark invoice as dirty when changing subevent through OCM. 2020-07-07 16:03:55 +02:00
Raphael Michel
640c05729b Backend: Improve asynctask status feedback 2020-07-07 10:42:06 +02:00
Raphael Michel
fc9e5166da Order data and invoice data export: Add payment types 2020-07-07 09:14:06 +02:00
Raphael Michel
b1eb5bb3df Fix incorrect link 2020-07-06 15:37:16 +02:00
Raphael Michel
f690d74be7 Event list: Respect event date in "running" filter 2020-07-03 17:46:41 +02:00
Raphael Michel
c52fdc95a7 Allow to disable display of foreign currencies on invoices 2020-07-03 16:44:26 +02:00
Raphael Michel
039ca36233 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-07-03 14:58:08 +02:00
Raphael Michel
9920a47580 Multi export: Allow restriction to organizer 2020-07-03 11:56:36 +02:00
Raphael Michel
74acfbe2fd Fix issue with new pycountry version 2020-07-02 20:11:39 +02:00
Raphael Michel
b0b8f32cb9 Always show net total in backend 2020-07-02 19:32:11 +02:00
Raphael Michel
066cf510e3 Add tax total to cart view even if not showing net prices 2020-07-02 19:31:43 +02:00
Raphael Michel
aca963d960 Fix tax rate changes if there hasn't been a tax rate before 2020-07-02 19:24:18 +02:00
Raphael Michel
582c7b50f7 Do not parse list in rich_text_snippet 2020-07-02 12:00:18 +02:00
Martin Gross
d6b185193e Fix Timezone in Checkinlist 2020-07-01 12:24:04 +02:00
Raphael Michel
fb92d500be Fix accidentally commited settings changes 2020-07-01 10:43:37 +02:00
Raphael Michel
27ed9ae4fd Adjust colors in question statistics 2020-06-30 23:57:47 +02:00
Raphael Michel
06fbf56c04 Question stats: Show percentage 2020-06-30 23:18:12 +02:00
Raphael Michel
d843fc1545 Fix colors in graph of boolean questions 2020-06-30 23:04:17 +02:00
Raphael Michel
cf2af3c94d Import/startup performance improvements 2020-06-30 11:36:30 +02:00
Martin Gross
5f50aa95eb Add TaxRule selection in OrderPositionChange (#1700)
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2020-06-30 11:13:33 +02:00
Raphael Michel
626e332886 Fix issue displaying unlimited quota 2020-06-30 10:04:45 +02:00
Gamy
507e1a5b83 Added various missing punctuation marks, changed "following" to "selected" to match the display. 2020-06-30 09:20:11 +02:00
MrGamy
0d1aa2f96e Localization string unification changes
unifying the text bit 'Go to shop' to display 'shop' with a lowercase s in base.html
2020-06-29 18:02:13 +02:00
Raphael Michel
df6038e39b Merge pull request #1703 from pretix/quota_release_after_exit
Allow to release quota after exit scans
2020-06-29 14:35:39 +02:00
Raphael Michel
922f12f55e Allow to release quota after exit scans 2020-06-26 16:49:19 +02:00
Raphael Michel
fdea190d72 Merge pull request #1702 from pretix-translations/weblate-pretix-pretix
Co-authored-by: Frank <webappconcept@gmail.com>
Co-authored-by: Maarten van den Berg <maartenberg1@gmail.com>
2020-06-25 12:46:43 +02:00
Maarten van den Berg
34fe34d50a Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-06-24 19:40:23 +02:00
Frank
3412c1d2a9 Translated on translate.pretix.eu (Italian)
Currently translated at 16.9% (623 of 3684 strings)

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

powered by weblate
2020-06-24 19:40:23 +02:00
Maarten van den Berg
6f58f30d92 Translated on translate.pretix.eu (Dutch)
Currently translated at 99.8% (3676 of 3684 strings)

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

powered by weblate
2020-06-24 19:40:23 +02:00
Frank
19b5610503 Translated on translate.pretix.eu (Italian)
Currently translated at 15.4% (569 of 3684 strings)

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

powered by weblate
2020-06-24 19:40:23 +02:00
Raphael Michel
55670b92a8 Merge branch 'master' of github.com:pretix/pretix 2020-06-24 19:40:12 +02:00
Raphael Michel
e5cc15ffac Improve responsiveness of organizer page 2020-06-24 19:39:57 +02:00
Martin Gross
249e6978ea Display subevent time for each item in addon and question step 2020-06-24 17:06:25 +02:00
Martin Gross
a223d57124 Reduce minimal waitinglist voucher validity to 1 hour 2020-06-24 13:26:38 +02:00
Raphael Michel
2a5c24482e Question list: Drop pagination, allow to mix ordering with system fields 2020-06-23 13:05:54 +02:00
Martin Gross
868292f9b3 Fix All Invoice-export (missing file extension; missing "all payment providers") 2020-06-23 10:58:39 +02:00
Raphael Michel
5c24fd966a Fix locale error 2020-06-22 16:09:55 +02:00
Raphael Michel
61490a9ee8 Merge pull request #1701 from pretix-translations/weblate-pretix-pretix 2020-06-22 09:42:55 +02:00
Frank
3c3333c485 Translated on translate.pretix.eu (Italian)
Currently translated at 15.3% (565 of 3684 strings)

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

powered by weblate
2020-06-22 09:42:19 +02:00
Frank
4c19002be6 Translated on translate.pretix.eu (Italian)
Currently translated at 14.7% (541 of 3684 strings)

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

powered by weblate
2020-06-20 19:11:38 +02:00
Frank
2c49eaeef8 Translated on translate.pretix.eu (Italian)
Currently translated at 14.7% (540 of 3684 strings)

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

powered by weblate
2020-06-20 19:11:38 +02:00
Raphael Michel
481e29c3b2 Allow to explicitly disable products for certain subevents 2020-06-20 19:10:44 +02:00
Raphael Michel
0aebde62eb Fix missing variation attributes when copying items 2020-06-20 18:21:12 +02:00
Raphael Michel
49e44f68ba Merge pull request #1699 from pretix-translations/weblate-pretix-pretix 2020-06-19 13:10:52 +02:00
Frank
84dc9f241d Translated on translate.pretix.eu (Italian)
Currently translated at 14.6% (538 of 3684 strings)

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

powered by weblate
2020-06-19 13:07:28 +02:00
Frank
07b05f4a44 Translated on translate.pretix.eu (Italian)
Currently translated at 13.6% (500 of 3684 strings)

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

powered by weblate
2020-06-19 01:00:12 +02:00
Raphael Michel
d1c1aed1f2 Widget: Improve support for week calendars 2020-06-18 17:43:22 +02:00
Raphael Michel
de9f7248cc Widget: Fix issue navigating back to month calendar 2020-06-18 16:38:58 +02:00
Raphael Michel
0d45706608 Try to fix widget bug in IE introduced in ebb1cc1be 2020-06-18 12:33:26 +02:00
Raphael Michel
016dd88e8b Remove Italian from incubating languages 2020-06-18 12:13:28 +02:00
Raphael Michel
6362c27cba Merge pull request #1697 from pretix-translations/weblate-pretix-pretix 2020-06-18 12:12:57 +02:00
Frank
7396f29b82 Translated on translate.pretix.eu (Italian)
Currently translated at 11.6% (427 of 3684 strings)

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

powered by weblate
2020-06-18 12:00:52 +02:00
Frank
ff9f6b6a36 Translated on translate.pretix.eu (Italian)
Currently translated at 10.3% (378 of 3684 strings)

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

powered by weblate
2020-06-17 18:08:14 +02:00
Raphael Michel
7359b5543d Set calendar as default for event series 2020-06-17 18:07:54 +02:00
Raphael Michel
74a0cafa0f Revert "Switch to calendar at 25 subevents already"
This reverts commit 8001063347.
2020-06-17 18:07:07 +02:00
Raphael Michel
16472e915d PDF: Show event time on default layout 2020-06-17 12:27:38 +02:00
Martin Gross
ec6844f900 Display Boxoffice iZettle payment details 2020-06-16 22:11:54 +02:00
Raphael Michel
e6455f8204 Organizer-level export: Fix incorrect event queryset 2020-06-16 12:03:59 +02:00
Raphael Michel
4c48fcd861 Move export down in organizer navigation 2020-06-16 11:39:44 +02:00
Raphael Michel
adfd7834fb Add subevent date to ticket filename 2020-06-16 11:37:47 +02:00
Raphael Michel
a4b8315487 Merge pull request #1695 from pretix-translations/weblate-pretix-pretix 2020-06-16 11:07:08 +02:00
Frank
8594fecad4 Translated on translate.pretix.eu (Italian)
Currently translated at 9.0% (331 of 3684 strings)

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

powered by weblate
2020-06-16 11:06:46 +02:00
Frank
61979f0c40 Translated on translate.pretix.eu (Italian)
Currently translated at 8.4% (311 of 3684 strings)

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

powered by weblate
2020-06-16 11:06:46 +02:00
Frank
df5f8a340b Translated on translate.pretix.eu (Italian)
Currently translated at 8.4% (310 of 3684 strings)

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

powered by weblate
2020-06-16 11:06:46 +02:00
Frank
651e797264 Translated on translate.pretix.eu (Italian)
Currently translated at 7.9% (290 of 3684 strings)

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

powered by weblate
2020-06-16 11:06:46 +02:00
Frank
d40010fab6 Translated on translate.pretix.eu (Italian)
Currently translated at 7.4% (271 of 3684 strings)

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

powered by weblate
2020-06-16 11:06:45 +02:00
Frank
89c3d59e6d Translated on translate.pretix.eu (Italian)
Currently translated at 7.0% (257 of 3684 strings)

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

powered by weblate
2020-06-16 11:06:45 +02:00
Frank
b30569a941 Translated on translate.pretix.eu (Italian)
Currently translated at 100.0% (125 of 125 strings)

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

powered by weblate
2020-06-16 11:06:45 +02:00
Frank
fe230fe56d Translated on translate.pretix.eu (Italian)
Currently translated at 6.4% (236 of 3684 strings)

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

powered by weblate
2020-06-16 11:06:45 +02:00
Martin Gross
0b20d3f6f8 Organizer/MultiEvent-Exports (#1684)
Co-authored-by: Raphael Michel <michel@rami.io>
2020-06-16 11:06:40 +02:00
Raphael Michel
e895c13b28 Re-label "Buy tickets" button 2020-06-16 10:35:07 +02:00
Martin Gross
5cc0bd5d36 Optional PayPal Reference Prefix (Z#2359330) (#1696)
* Optional PayPal Reference Prefix (Z#2359330)

* Move prefix to end
2020-06-15 18:23:01 +02:00
Raphael Michel
569379e508 Order API: Add search 2020-06-15 15:12:09 +02:00
Raphael Michel
d975a68641 Allow to turn off CSP reporting 2020-06-15 15:12:09 +02:00
Raphael Michel
c992de341f Revert "PayPal: Add additional protection against invalid sessions"
This reverts commit 99e02bde36.
2020-06-14 12:29:20 +02:00
Raphael Michel
11cc27dbd6 Fix crash when trying to refund an order with a disabled payment mehtod 2020-06-12 14:01:15 +02:00
Raphael Michel
90e70eae25 Fix test (see 45f120b0c) 2020-06-12 13:58:49 +02:00
Raphael Michel
9eacd38ec7 PayPal: Improve handling of exceptions form paypalrestsdk 2020-06-12 13:21:44 +02:00
Raphael Michel
d1c96aa77c PayPal: Remove unused session key 2020-06-12 13:21:44 +02:00
Raphael Michel
99e02bde36 PayPal: Add additional protection against invalid sessions 2020-06-12 13:21:44 +02:00
Raphael Michel
e7da2aec53 PayPal: Fix critical bug leading to wrong order being paid in a rare session constellation 2020-06-12 13:21:44 +02:00
Raphael Michel
d0c6f0f0e9 Allow to shred data 30 days after event (instead of 60) 2020-06-11 10:44:27 +02:00
Raphael Michel
3ae148956f Add spellcheck lists 2020-06-10 18:24:00 +02:00
Raphael Michel
e0436039d2 Merge pull request #1694 from pretix-translations/weblate-pretix-pretix 2020-06-10 18:15:40 +02:00
Raphael Michel
29510b8617 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-06-10 18:13:54 +02:00
Raphael Michel
2d88da3a67 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3684 of 3684 strings)

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

powered by weblate
2020-06-10 18:13:53 +02:00
Raphael Michel
fbb88602d4 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2020-06-10 18:02:48 +02:00
Raphael Michel
7d7820a4ee Merge pull request #1693 from pretix/series-creation 2020-06-10 18:02:16 +02:00
Raphael Michel
8001063347 Switch to calendar at 25 subevents already 2020-06-10 18:01:52 +02:00
Raphael Michel
0c3a200355 Do not auto-create a first subevent 2020-06-10 17:59:59 +02:00
Maico Timmerman
ebb1cc1be7 Fix #1689 -- Widget: disable buy button without selected products (#1692) 2020-06-10 17:58:10 +02:00
Raphael Michel
45f120b0c3 API: Modified settings endpoint for devices 2020-06-10 17:45:31 +02:00
Martin Gross
bae0e45d00 Add W-indicator to Week-Calendar Dropdown 2020-06-10 12:59:21 +02:00
Martin Gross
057fd95706 Fix op.address_format() when no zip code is provided 2020-06-10 11:01:08 +02:00
Martin Gross
597d4aa206 Fix attendee_address 2020-06-10 10:32:20 +02:00
Raphael Michel
7f9b245eb5 Fix Preview button for ticket formats 2020-06-09 09:54:28 +02:00
Raphael Michel
42490c6dec New event series selection field 2020-06-08 16:20:50 +02:00
Raphael Michel
60c0b7da12 If date isn't shown on front page, don't show it on invoices 2020-06-08 14:49:06 +02:00
Raphael Michel
7d41922274 Minor improvemnet to widget views 2020-06-07 12:07:03 +02:00
Raphael Michel
fc7fbf31c5 Keep cached tickets around for a shorter interval 2020-06-05 12:10:10 +02:00
Raphael Michel
da5433325c Fix hardcoded ID in test 2020-06-05 10:03:16 +02:00
Raphael Michel
939a38d53b Fix subsequent issue on event level domains 2020-06-04 20:32:21 +02:00
Raphael Michel
a57280004e Fix another multidomain issue 2020-06-04 20:15:26 +02:00
Raphael Michel
ce896cec8f Fix bug in previosu commit 2020-06-04 19:05:07 +02:00
Raphael Michel
effc9723f1 Add event meta data fields to order search form 2020-06-04 18:39:30 +02:00
Raphael Michel
cd5f6b66a1 Do not cache event/organizer instance in multi domain middleware (might be harmful) 2020-06-04 18:33:15 +02:00
Raphael Michel
0d35064d21 Order create API: Fix addon_to in simulated mode
PRETIXEU-275
2020-06-02 16:48:52 +02:00
Raphael Michel
314ce5467f Disable autocomplete for all date/time picker fields 2020-06-02 12:42:12 +02:00
Raphael Michel
d97ef380a4 Bump to 3.10.0.dev0 2020-06-02 12:33:17 +02:00
295 changed files with 88641 additions and 70079 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

@@ -95,6 +95,8 @@ Example::
proxy that actively removes and re-adds the header to make sure the correct value is set.
Defaults to ``off``.
``csp_log``
Log violations of the Content Security Policy (CSP). Defaults to ``on``.
Locale settings
---------------
@@ -337,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

@@ -526,7 +526,7 @@ information about the properties.
Get current values of event settings.
Permission required: "Can change event settings"
Permission required: "Can change event settings" (Exception: with device auth, *some* settings can always be *read*.)
**Example request**:

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

@@ -155,6 +155,14 @@ last_modified datetime Last modificati
The ``reactivate`` operation has been added.
.. versionchanged:: 3.10
The ``search`` query parameter has been added.
.. versionchanged:: 3.11
The ``exclude`` and ``subevent_after`` query parameter has been added.
.. _order-position-resource:
@@ -468,6 +476,7 @@ List of all orders
``last_modified``, and ``status``. Default: ``datetime``
:query string code: Only return orders that match the given order code
:query string status: Only return orders in the given order status (see above)
:query string search: Only return orders matching a given search query
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
``require_approval`` will be returned.
@@ -480,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
@@ -923,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

@@ -26,6 +26,8 @@ close_when_sold_out boolean If ``true``, th
again.
closed boolean Whether the quota is currently closed (see above
field).
release_after_exit boolean Whether the quota regains capacity as soon as some tickets
have been scanned at an exit.
===================================== ========================== =======================================================
.. versionchanged:: 1.10
@@ -36,6 +38,10 @@ closed boolean Whether the quo
The attributes ``close_when_sold_out`` and ``closed`` have been added.
.. versionchanged:: 3.10
The attribute ``release_after_exit`` has been added.
Endpoints
---------
@@ -283,6 +289,7 @@ Endpoints
"total_size": 1000,
"pending_orders": 25,
"paid_orders": 423,
"exited_orders": 0,
"cart_positions": 7,
"blocking_vouchers": 126,
"waiting_list": 0

View File

@@ -39,10 +39,12 @@ geo_lon float Longitude of th
item_price_overrides list of objects List of items for which this sub-event overrides the
default price
├ item integer The internal item ID
├ disabled boolean If ``true``, item should not be available for this sub-event
└ price money (string) The price or ``null`` for the default price
variation_price_overrides list of objects List of variations for which this sub-event overrides
the default price
├ variation integer The internal variation ID
├ disabled boolean If ``true``, variation should not be available for this sub-event
└ price money (string) The price or ``null`` for the default price
meta_data object Values set for organizer-specific meta data parameters.
seating_plan integer If reserved seating is in use, the ID of a seating
@@ -74,6 +76,10 @@ seat_category_mapping object An object mappi
The attributes ``geo_lat`` and ``geo_lon`` have been added.
.. versionchanged:: 3.10
The ``disabled`` attribute has been added to ``item_price_overrides`` and ``variation_price_overrides``.
Endpoints
---------
@@ -125,6 +131,7 @@ Endpoints
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"price": "12.00"
}
],
@@ -182,6 +189,7 @@ Endpoints
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"price": "12.00"
}
],
@@ -216,6 +224,7 @@ Endpoints
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"price": "12.00"
}
],
@@ -271,6 +280,7 @@ Endpoints
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"price": "12.00"
}
],
@@ -307,6 +317,7 @@ Endpoints
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"price": "23.42"
}
],
@@ -339,6 +350,7 @@ Endpoints
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"price": "23.42"
}
],
@@ -427,6 +439,7 @@ Endpoints
"item_price_overrides": [
{
"item": 2,
"disabled": false,
"price": "12.00"
}
],

View File

@@ -29,6 +29,22 @@ that we'll provide in this plugin::
from .exporter import MyExporter
return MyExporter
Some exporters might also prove to be useful, when provided on an organizer-level. In order to declare your
exporter as capable of providing exports spanning multiple events, your plugin should listen for this signal
and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin::
from django.dispatch import receiver
from pretix.base.signals import register_multievent_data_exporters
@receiver(register_multievent_data_exporters, dispatch_uid="multieventexporter_myexporter")
def register_multievent_data_exporter(sender, **kwargs):
from .exporter import MyExporter
return MyExporter
If your exporter supports both event-level and multi-event level exports, you will need to listen for both
signals.
The exporter class
------------------

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.9.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 {}
}
@@ -341,13 +348,13 @@ class CloneEventSerializer(EventSerializer):
class SubEventItemSerializer(I18nAwareModelSerializer):
class Meta:
model = SubEventItem
fields = ('item', 'price')
fields = ('item', 'price', 'disabled')
class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = SubEventItemVariation
fields = ('variation', 'price')
fields = ('variation', 'price', 'disabled')
class SubEventSerializer(I18nAwareModelSerializer):
@@ -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',
@@ -611,6 +621,7 @@ class EventSettingsSerializer(serializers.Serializer):
'invoice_introductory_text',
'invoice_additional_text',
'invoice_footer_text',
'invoice_eu_currencies',
'cancel_allow_user',
'cancel_allow_user_until',
'cancel_allow_user_paid',
@@ -622,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):
@@ -629,9 +643,13 @@ class EventSettingsSerializer(serializers.Serializer):
super().__init__(*args, **kwargs)
for fname in self.default_fields:
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
if callable(kwargs):
kwargs = kwargs()
kwargs.setdefault('required', False)
kwargs.setdefault('allow_null', True)
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
if callable(form_kwargs):
form_kwargs = form_kwargs()
if 'serializer_class' not in DEFAULTS[fname]:
raise ValidationError('{} has no serializer class'.format(fname))
f = DEFAULTS[fname]['serializer_class'](
@@ -660,3 +678,40 @@ class EventSettingsSerializer(serializers.Serializer):
settings_dict.update(data)
validate_settings(self.event, settings_dict)
return data
class DeviceEventSettingsSerializer(EventSettingsSerializer):
default_fields = [
'locales',
'locale',
'last_order_modification_date',
'show_quota_left',
'max_items_per_order',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_company_asked',
'attendee_company_required',
'ticket_download',
'ticket_download_addons',
'ticket_download_nonadm',
'ticket_download_pending',
'invoice_address_asked',
'invoice_address_required',
'invoice_address_vatid',
'invoice_address_company_required',
'invoice_address_beneficiary',
'invoice_address_custom_field',
'invoice_name_required',
'invoice_address_not_asked_free',
'invoice_address_from_name',
'invoice_address_from',
'invoice_address_from_zipcode',
'invoice_address_from_city',
'invoice_address_from_country',
'invoice_address_from_tax_id',
'invoice_address_from_vat_id',
]

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)
@@ -349,7 +349,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
class Meta:
model = Quota
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out')
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out', 'release_after_exit')
def validate(self, data):
data = super().validate(data)

View File

@@ -68,7 +68,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country')):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
@@ -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))
@@ -416,16 +425,26 @@ class OrderSerializer(I18nAwareModelSerializer):
return instance
class SimulatedOrderPositionSerializer(OrderPositionSerializer):
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
class SimulatedOrderSerializer(OrderSerializer):
positions = SimulatedOrderPositionSerializer(many=True, read_only=True)
class PriceCalcSerializer(serializers.Serializer):
item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
tax_rule = serializers.PrimaryKeyRelatedField(queryset=TaxRule.objects.none(), required=False, allow_null=True)
locale = serializers.CharField(allow_null=True, required=False)
def __init__(self, *args, **kwargs):
event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['item'].queryset = event.items.all()
self.fields['tax_rule'].queryset = event.tax_rules.all()
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=event)
if event.has_subevents:
self.fields['subevent'].queryset = event.subevents.all()
@@ -590,7 +609,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country')):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
@@ -903,6 +922,19 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
continue
if pos_data.get('subevent'):
if pos_data.get('item').pk in pos_data['subevent'].item_overrides and pos_data['subevent'].item_overrides[pos_data['item'].pk].disabled:
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
str(pos_data.get('item'))
)]
if (
pos_data.get('variation') and pos_data['variation'].pk in pos_data['subevent'].var_overrides and
pos_data['subevent'].var_overrides[pos_data['variation'].pk].disabled
):
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
str(pos_data.get('item'))
)]
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
@@ -963,7 +995,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
pos.order = order
if addon_to:
pos.addon_to = pos_map[addon_to]
if simulate:
pos.addon_to = pos_map[addon_to]._wrapped
else:
pos.addon_to = pos_map[addon_to]
if pos.price is None:
price = get_price(

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

@@ -10,8 +10,8 @@ from rest_framework.response import Response
from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.serializers.event import (
CloneEventSerializer, EventSerializer, EventSettingsSerializer,
SubEventSerializer, TaxRuleSerializer,
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
EventSettingsSerializer, SubEventSerializer, TaxRuleSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
@@ -337,10 +337,16 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
class EventSettingsView(views.APIView):
permission = 'can_change_event_settings'
permission = None
write_permission = 'can_change_event_settings'
def get(self, request, *args, **kwargs):
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
if isinstance(request.auth, Device):
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event)
elif 'can_change_event_settings' in request.eventpermset:
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
else:
raise PermissionDenied()
if 'explain' in request.GET:
return Response({
fname: {

View File

@@ -542,6 +542,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
data = {
'paid_orders': qa.count_paid_orders[quota],
'pending_orders': qa.count_pending_orders[quota],
'exited_orders': qa.count_exited_orders[quota],
'blocking_vouchers': qa.count_vouchers[quota],
'cart_positions': qa.count_cart[quota],
'waiting_list': qa.count_pending_orders[quota],

View File

@@ -4,7 +4,7 @@ from decimal import Decimal
import django_filters
import pytz
from django.db import transaction
from django.db.models import F, Prefetch, Q
from django.db.models import Exists, F, OuterRef, Prefetch, Q
from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404
@@ -26,12 +26,12 @@ from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
OrderPaymentSerializer, OrderPositionSerializer,
OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
PriceCalcSerializer,
PriceCalcSerializer, SimulatedOrderSerializer,
)
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
@@ -52,6 +52,7 @@ from pretix.base.signals import (
order_modified, order_paid, order_placed, register_ticket_outputs,
)
from pretix.base.templatetags.money import money_filter
from pretix.control.signals import order_search_filter_q
with scopes_disabled():
class OrderFilter(FilterSet):
@@ -60,11 +61,62 @@ 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:
code = (Q(event__slug__icontains=u.rsplit("-", 1)[0])
& Q(code__icontains=Order.normalize_code(u.rsplit("-", 1)[1])))
else:
code = Q(code__icontains=Order.normalize_code(u))
matching_invoices = Invoice.objects.filter(
Q(invoice_no__iexact=u)
| 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(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
)
).values('id')
mainq = (
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)
)
for recv, q in order_search_filter_q.send(sender=getattr(self, 'event', None), query=u):
mainq = mainq | q
return qs.annotate(has_pos=Exists(matching_positions)).filter(
mainq
)
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
@@ -83,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
@@ -129,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())
@@ -488,10 +544,12 @@ class OrderViewSet(viewsets.ModelViewSet):
self.perform_create(serializer)
send_mail = serializer._send_mail
order = serializer.instance
serializer = OrderSerializer(order, context=serializer.context)
if not order.pk:
# Simulation
serializer = SimulatedOrderSerializer(order, context=serializer.context)
return Response(serializer.data, status=status.HTTP_201_CREATED)
else:
serializer = OrderSerializer(order, context=serializer.context)
order.log_action(
'pretix.event.order.placed',
@@ -743,7 +801,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
{
"item": 2,
"variation": null,
"subevent": 3
"subevent": 3,
"tax_rule": 4,
}
Sample output:
@@ -797,7 +856,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
if data.get('subevent'):
kwargs['subevent'] = data.get('subevent')
if data.get('tax_rule'):
kwargs['tax_rule'] = data.get('tax_rule')
price = get_price(**kwargs)
tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule)
with language(data.get('locale') or self.request.event.settings.locale):
return Response({
'gross': price.gross,
@@ -806,6 +869,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
'rate': price.rate,
'name': str(price.name),
'tax': price.tax,
'tax_rule': tr.pk if tr else None,
})
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')

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,24 +1,34 @@
import io
import tempfile
from collections import OrderedDict
from collections import OrderedDict, namedtuple
from decimal import Decimal
from typing import Tuple
from defusedcsv import csv
from django import forms
from django.db.models import QuerySet
from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _
from openpyxl import Workbook
from openpyxl.cell.cell import KNOWN_TYPES
from pretix.base.models import Event
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
self.event = None
else:
self.events = Event.objects.filter(pk=event.pk)
def __str__(self):
return self.identifier
@@ -85,6 +95,7 @@ class BaseExporter:
class ListExporter(BaseExporter):
ProgressSetTotal = namedtuple('ProgressSetTotal', 'total')
@property
def export_form_fields(self) -> dict:
@@ -118,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)
@@ -203,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,81 +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 OrderPayment
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
from ..exporter import BaseExporter
from ...control.forms.filter import get_all_payment_providers
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
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 = self.event.invoices.filter(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 output_file:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', None
else:
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
class InvoiceExporterMixin:
@property
def export_form_fields(self):
def invoice_exporter_form_fields(self):
return OrderedDict(
[
('date_from',
@@ -99,6 +51,8 @@ class InvoiceExporter(BaseExporter):
label=_('Payment provider'),
choices=[
('', _('All payment providers')),
] + get_all_payment_providers() if self.is_multievent else [
('', _('All payment providers')),
] + [
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
],
@@ -111,7 +65,339 @@ 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):
return InvoiceExporter
@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

@@ -8,7 +8,9 @@ from pretix.base.models import OrderPosition
from ..exporter import BaseExporter
from ..models import Order
from ..signals import register_data_exporters
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
class MailExporter(BaseExporter):
@@ -16,14 +18,18 @@ class MailExporter(BaseExporter):
verbose_name = _('Email addresses (text file)')
def render(self, form_data: dict):
qs = self.event.orders.filter(status__in=form_data['status'])
qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event')
addrs = qs.values('email')
pos = OrderPosition.objects.filter(
order__event=self.event, order__status__in=form_data['status']
order__event__in=self.events, order__status__in=form_data['status']
).values('attendee_email')
data = "\r\n".join(set(a['email'] for a in addrs if a['email'])
| set(a['attendee_email'] for a in pos if a['attendee_email']))
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")
if self.is_multievent:
return '{}_pretixemails.txt'.format(self.events.first().organizer.slug), 'text/plain', data.encode("utf-8")
else:
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")
@property
def export_form_fields(self):
@@ -44,3 +50,8 @@ class MailExporter(BaseExporter):
@receiver(register_data_exporters, dispatch_uid="exporter_mail")
def register_mail_export(sender, **kwargs):
return MailExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_mail")
def register_multievent_mail_export(sender, **kwargs):
return MailExporter

View File

@@ -4,27 +4,37 @@ from decimal import Decimal
import pytz
from django import forms
from django.db.models import (
Count, DateTimeField, F, IntegerField, Max, OuterRef, Subquery, Sum,
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, 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
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
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
class OrderListExporter(MultiSheetListExporter):
identifier = 'orderlist'
verbose_name = gettext_lazy('Order data')
@cached_property
def providers(self):
return dict(get_all_payment_providers())
@property
def sheets(self):
return (
@@ -50,13 +60,13 @@ class OrderListExporter(MultiSheetListExporter):
tax_rates = set(
a for a
in OrderFee.objects.filter(
order__event=self.event
order__event__in=self.events
).values_list('tax_rate', flat=True).distinct().order_by()
)
tax_rates |= set(
a for a
in OrderPosition.objects.filter(
order__event=self.event
order__event__in=self.events
).values_list('tax_rate', flat=True).distinct().order_by()
)
tax_rates = sorted(tax_rates)
@@ -70,9 +80,11 @@ class OrderListExporter(MultiSheetListExporter):
elif sheet == 'fees':
return self.iterate_fees(form_data)
def iterate_orders(self, form_data: dict):
tz = pytz.timezone(self.event.settings.timezone)
@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'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
@@ -82,24 +94,42 @@ class OrderListExporter(MultiSheetListExporter):
).values(
'm'
).order_by()
p_providers = OrderPayment.objects.filter(
order=OuterRef('pk'),
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()
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')
).order_by().values('order').annotate(k=Count('id')).values('k')
qs = self.event.orders.annotate(
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')
).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 = [
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Company'), _('Name'),
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Order time'), _('Company'), _('Name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
if len(name_scheme['fields']) > 1:
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:
for k, label, w in name_scheme['fields']:
headers.append(label)
headers += [
@@ -119,6 +149,7 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Requires special attention'))
headers.append(_('Comment'))
headers.append(_('Positions'))
headers.append(_('Payment providers'))
yield headers
@@ -139,20 +170,25 @@ class OrderListExporter(MultiSheetListExporter):
)
}
for order in qs.order_by('datetime'):
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 = [
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 += [
order.invoice_address.company,
order.invoice_address.name,
]
if len(name_scheme['fields']) > 1:
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, '')
@@ -167,7 +203,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
@@ -189,27 +225,42 @@ 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 "")
row.append(order.pcnt)
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((order.payment_providers or '').split(',')))
if p and p != 'free'
]))
yield row
def iterate_fees(self, form_data: dict):
tz = pytz.timezone(self.event.settings.timezone)
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 = OrderFee.objects.filter(
order__event=self.event,
order__event__in=self.events,
).annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related('order', 'order__invoice_address', 'tax_rule')
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
headers = [
_('Event slug'),
_('Order code'),
_('Status'),
_('Email'),
_('Order date'),
_('Order time'),
_('Fee type'),
_('Description'),
_('Price'),
@@ -219,23 +270,28 @@ class OrderListExporter(MultiSheetListExporter):
_('Company'),
_('Invoice address name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
if len(name_scheme['fields']) > 1:
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:
for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label))
headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
]
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 = [
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,
@@ -248,7 +304,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.company,
order.invoice_address.name,
]
if len(name_scheme['fields']) > 1:
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, '')
@@ -263,14 +319,28 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
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 iterate_positions(self, form_data: dict):
tz = pytz.timezone(self.event.settings.timezone)
qs = OrderPosition.objects.filter(
order__event=self.event,
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 = OrderPosition.objects.filter(
order__event__in=self.events,
)
qs = base_qs.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related(
'order', 'order__invoice_address', 'item', 'variation',
'voucher', 'tax_rule'
@@ -280,14 +350,18 @@ 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'),
_('Position ID'),
_('Status'),
_('Email'),
_('Order date'),
_('Order time'),
]
if self.event.has_subevents:
if has_subevents:
headers.append(pgettext('subevent', 'Date'))
headers.append(_('Start date'))
headers.append(_('End date'))
@@ -300,8 +374,8 @@ class OrderListExporter(MultiSheetListExporter):
_('Tax value'),
_('Attendee name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
if len(name_scheme['fields']) > 1:
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:
for k, label, w in name_scheme['fields']:
headers.append(_('Attendee name') + ': ' + str(label))
headers += [
@@ -315,7 +389,8 @@ class OrderListExporter(MultiSheetListExporter):
_('Voucher'),
_('Pseudonymization ID'),
]
questions = list(self.event.questions.all())
questions = list(Question.objects.filter(event__in=self.events))
options = {}
for q in questions:
if q.type == Question.TYPE_CHOICE_MULTIPLE:
@@ -329,7 +404,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Company'),
_('Invoice address name'),
]
if len(name_scheme['fields']) > 1:
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label))
headers += [
@@ -337,97 +412,118 @@ class OrderListExporter(MultiSheetListExporter):
]
headers += [
_('Sales channel'), _('Order locale'),
_('Payment providers'),
]
yield headers
for op in qs.order_by('order__datetime', 'positionid'):
order = op.order
row = [
order.code,
op.positionid,
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
]
if self.event.has_subevents:
row.append(op.subevent.name)
row.append(op.subevent.date_from.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
if op.subevent.date_to:
row.append(op.subevent.date_to.astimezone(self.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 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:
row += [
order.invoice_address.company,
order.invoice_address.name,
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 len(name_scheme['fields']) > 1:
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 += [
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 len(name_scheme['fields']) > 1 else 0))
row += [
order.sales_channel,
order.locale
]
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):
return '{}_orders'.format(self.event.slug)
if self.is_multievent:
return '{}_orders'.format(self.events.first().organizer.slug)
else:
return '{}_orders'.format(self.event.slug)
class PaymentListExporter(ListExporter):
@@ -459,31 +555,28 @@ class PaymentListExporter(ListExporter):
)
def iterate_list(self, form_data):
tz = pytz.timezone(self.event.settings.timezone)
provider_names = {
k: v.verbose_name
for k, v in self.event.get_payment_providers().items()
}
provider_names = dict(get_all_payment_providers())
payments = OrderPayment.objects.filter(
order__event=self.event,
order__event__in=self.events,
state__in=form_data.get('payment_states', [])
).order_by('created')
refunds = OrderRefund.objects.filter(
order__event=self.event,
order__event__in=self.events,
state__in=form_data.get('refund_states', [])
).order_by('created')
objs = sorted(list(payments) + list(refunds), key=lambda o: o.created)
headers = [
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Status code'), _('Amount'), _('Payment method')
]
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:
d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d')
elif isinstance(obj, OrderRefund) and obj.execution_date:
@@ -491,6 +584,7 @@ class PaymentListExporter(ListExporter):
else:
d2 = ''
row = [
obj.order.event.slug,
obj.order.code,
obj.full_id,
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
@@ -503,7 +597,10 @@ class PaymentListExporter(ListExporter):
yield row
def get_filename(self):
return '{}_payments'.format(self.event.slug)
if self.is_multievent:
return '{}_payments'.format(self.events.first().organizer.slug)
else:
return '{}_payments'.format(self.event.slug)
class QuotaListExporter(ListExporter):
@@ -513,7 +610,7 @@ class QuotaListExporter(ListExporter):
def iterate_list(self, form_data):
headers = [
_('Quota name'), _('Total quota'), _('Paid orders'), _('Pending orders'), _('Blocking vouchers'),
_('Current user\'s carts'), _('Waiting list'), _('Current availability')
_('Current user\'s carts'), _('Waiting list'), _('Exited orders'), _('Current availability')
]
yield headers
@@ -532,6 +629,7 @@ class QuotaListExporter(ListExporter):
qa.count_vouchers[quota],
qa.count_cart[quota],
qa.count_waitinglist[quota],
qa.count_exited_orders[quota],
_('Infinite') if avail[1] is None else avail[1]
]
yield row
@@ -540,218 +638,32 @@ 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'),
]
qs = self.event.invoices.order_by('full_invoice_no').select_related(
'order', 'refers'
).prefetch_related('order__payments').annotate(
total_gross=Subquery(
InvoiceLine.objects.filter(
invoice=OuterRef('pk')
).order_by().values('invoice').annotate(
s=Sum('gross_value')
).values('s')
),
total_net=Subquery(
InvoiceLine.objects.filter(
invoice=OuterRef('pk')
).order_by().values('invoice').annotate(
s=Sum(F('gross_value') - F('tax_value'))
).values('s')
)
)
for i in qs:
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
]
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'),
]
qs = InvoiceLine.objects.filter(
invoice__event=self.event
).order_by('invoice__full_invoice_no', 'position').select_related(
'invoice', 'invoice__order', 'invoice__refers'
)
for l in qs:
i = l.invoice
yield [
i.full_invoice_no,
l.position + 1,
l.description.replace("<br />", " - "),
l.gross_value,
l.net_value,
l.tax_value,
l.tax_rate,
l.tax_name,
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
i.order.email,
_('Cancellation') if i.is_cancellation else _('Invoice'),
i.refers.full_invoice_no if i.refers else '',
i.invoice_from_name,
i.invoice_from,
i.invoice_from_zipcode,
i.invoice_from_city,
i.invoice_from_country,
i.invoice_from_tax_id,
i.invoice_from_vat_id,
i.invoice_to_company,
i.invoice_to_name,
i.invoice_to_street or i.invoice_to,
i.invoice_to_zipcode,
i.invoice_to_city,
i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id,
i.invoice_to_beneficiary,
i.internal_reference,
]
def get_filename(self):
return '{}_invoices'.format(self.event.slug)
class GiftcardRedemptionListExporter(ListExporter):
identifier = 'giftcardredemptionlist'
verbose_name = gettext_lazy('Gift card redemptions')
def iterate_list(self, form_data):
tz = pytz.timezone(self.event.settings.timezone)
payments = OrderPayment.objects.filter(
order__event=self.event,
order__event__in=self.events,
provider='giftcard'
).order_by('created')
refunds = OrderRefund.objects.filter(
order__event=self.event,
order__event__in=self.events,
provider='giftcard'
).order_by('created')
objs = sorted(list(payments) + list(refunds), key=lambda o: (o.order.code, o.created))
headers = [
_('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer')
_('Event slug'), _('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer')
]
yield headers
for obj in objs:
tz = pytz.timezone(obj.order.event.settings.timezone)
gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card'))
row = [
obj.order.event.slug,
obj.order.code,
obj.full_id,
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
@@ -762,7 +674,10 @@ class GiftcardRedemptionListExporter(ListExporter):
yield row
def get_filename(self):
return '{}_giftcardredemptions'.format(self.event.slug)
if self.is_multievent:
return '{}_giftcardredemptions'.format(self.events.first().organizer.slug)
else:
return '{}_giftcardredemptions'.format(self.event.slug)
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
@@ -770,21 +685,31 @@ def register_orderlist_exporter(sender, **kwargs):
return OrderListExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_orderlist")
def register_multievent_orderlist_exporter(sender, **kwargs):
return OrderListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_paymentlist")
def register_paymentlist_exporter(sender, **kwargs):
return PaymentListExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_paymentlist")
def register_multievent_paymentlist_exporter(sender, **kwargs):
return PaymentListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
def register_quotalist_exporter(sender, **kwargs):
return QuotaListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
def register_invoicedata_exporter(sender, **kwargs):
return InvoiceDataExporter
@receiver(register_data_exporters, dispatch_uid="exporter_giftcardredemptionlist")
def register_giftcardredemptionlist_exporter(sender, **kwargs):
return GiftcardRedemptionListExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardredemptionlist")
def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
return GiftcardRedemptionListExporter

View File

@@ -65,6 +65,8 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
super().__init__(*args, **kwargs)
for fname in self.auto_fields:
kwargs = DEFAULTS[fname].get('form_kwargs', {})
if callable(kwargs):
kwargs = kwargs()
kwargs.setdefault('required', False)
field = DEFAULTS[fname]['form_class'](
**kwargs

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
@@ -244,8 +248,10 @@ class BaseQuestionsForm(forms.Form):
super().__init__(*args, **kwargs)
add_fields = {}
if item.admission and event.settings.attendee_names_asked:
self.fields['attendee_name_parts'] = NamePartsFormField(
add_fields['attendee_name_parts'] = NamePartsFormField(
max_length=255,
required=event.settings.attendee_names_required and not self.all_optional,
scheme=event.settings.name_scheme,
@@ -254,7 +260,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
)
if item.admission and event.settings.attendee_emails_asked:
self.fields['attendee_email'] = forms.EmailField(
add_fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required and not self.all_optional,
label=_('Attendee email'),
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
@@ -265,14 +271,15 @@ class BaseQuestionsForm(forms.Form):
)
)
if item.admission and event.settings.attendee_company_asked:
self.fields['company'] = forms.CharField(
add_fields['company'] = forms.CharField(
required=event.settings.attendee_company_required and not self.all_optional,
label=_('Company'),
max_length=255,
initial=(cartpos.company if cartpos else orderpos.company),
)
if item.admission and event.settings.attendee_addresses_asked:
self.fields['street'] = forms.CharField(
add_fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'),
widget=forms.Textarea(attrs={
@@ -282,24 +289,26 @@ class BaseQuestionsForm(forms.Form):
}),
initial=(cartpos.street if cartpos else orderpos.street),
)
self.fields['zipcode'] = forms.CharField(
add_fields['zipcode'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
max_length=30,
label=_('ZIP code'),
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
widget=forms.TextInput(attrs={
'autocomplete': 'postal-code',
}),
)
self.fields['city'] = forms.CharField(
add_fields['city'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('City'),
max_length=255,
initial=(cartpos.city if cartpos else orderpos.city),
widget=forms.TextInput(attrs={
'autocomplete': 'address-level2',
}),
)
country = (cartpos.country if cartpos else orderpos.country) or guess_country(event)
self.fields['country'] = CountryField(
add_fields['country'] = CountryField(
countries=CachedCountries
).formfield(
required=event.settings.attendee_addresses_required and not self.all_optional,
@@ -324,7 +333,7 @@ class BaseQuestionsForm(forms.Form):
self.data = self.data.copy()
del self.data[fprefix + 'state']
self.fields['state'] = forms.ChoiceField(
add_fields['state'] = forms.ChoiceField(
label=pgettext_lazy('address', 'State'),
required=False,
choices=c,
@@ -332,7 +341,14 @@ class BaseQuestionsForm(forms.Form):
'autocomplete': 'address-level1',
}),
)
self.fields['state'].widget.is_required = True
add_fields['state'].widget.is_required = True
field_positions = list(
[
(n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0))
for n in add_fields.keys()
]
)
for q in questions:
# Do we already have an answer? Provide it as the initial value
@@ -385,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(
@@ -485,7 +502,12 @@ class BaseQuestionsForm(forms.Form):
field._required = q.required and not self.all_optional
field.required = False
self.fields['question_%s' % q.id] = field
add_fields['question_%s' % q.id] = field
field_positions.append(('question_%s' % q.id, q.position))
field_positions.sort(key=lambda e: e[1])
for fname, p in field_positions:
self.fields[fname] = add_fields[fname]
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
@@ -532,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
@@ -566,7 +589,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization',
}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-in-eu': ','.join(EU_COUNTRIES)}),
'internal_reference': forms.TextInput,
}
labels = {
@@ -616,6 +639,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
)
self.fields['state'].widget.is_required = True
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
if cc and cc not in EU_COUNTRIES and fprefix + 'vat_id' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'vat_id']
if not event.settings.invoice_address_required or self.all_optional:
for k, f in self.fields.items():
f.required = False
@@ -630,8 +658,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['company'].widget.is_required = True
self.fields['company'].widget.attrs['required'] = 'required'
del self.fields['company'].widget.attrs['data-display-dependency']
if 'vat_id' in self.fields:
del self.fields['vat_id'].widget.attrs['data-display-dependency']
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
@@ -663,6 +689,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
data = self.cleaned_data
if not data.get('is_business'):
data['company'] = ''
data['vat_id'] = ''
if data.get('is_business') and not data.get('country') in EU_COUNTRIES:
data['vat_id'] = ''
if self.event.settings.invoice_address_required:
if data.get('is_business') and not data.get('company'):
raise ValidationError(_('You need to provide a company name.'))
@@ -683,7 +712,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
) and len(data.get('name_parts', {})) == 1:
# Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = ''
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):

View File

@@ -15,6 +15,7 @@ class DatePickerWidget(forms.DateInput):
date_attrs = dict(attrs)
date_attrs.setdefault('class', 'form-control')
date_attrs['class'] += ' datepickerfield'
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
date_attrs['placeholder'] = now().replace(
@@ -32,6 +33,7 @@ class TimePickerWidget(forms.TimeInput):
time_attrs = dict(attrs)
time_attrs.setdefault('class', 'form-control')
time_attrs['class'] += ' timepickerfield'
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
time_attrs['placeholder'] = now().replace(
@@ -102,6 +104,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
time_attrs.setdefault('autocomplete', 'off')
date_attrs['class'] += ' datepickerfield'
time_attrs['class'] += ' timepickerfield'
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
def date_placeholder():
df = date_format or get_format('DATE_INPUT_FORMATS')[0]

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:
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,
@@ -212,8 +210,9 @@ class SecurityMiddleware(MiddlewareMixin):
# single-sign-on this can be nearly anything so we cannot really restrict
# this. However, we'll restrict it to HTTPS.
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
'report-uri': ["/csp_report/"],
}
if settings.LOG_CSP:
h['report-uri'] = ["/csp_report/"]
if 'Content-Security-Policy' in resp:
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))

View File

@@ -0,0 +1,31 @@
# Generated by Django 3.0.6 on 2020-06-20 16:33
import django_countries.fields
from django.db import migrations, models
import pretix.helpers.countries
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0153_auto_20200528_1953'),
]
operations = [
migrations.AddField(
model_name='subeventitem',
name='disabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='subeventitemvariation',
name='disabled',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='invoiceaddress',
name='country',
field=django_countries.fields.CountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.6 on 2020-06-26 14:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0154_auto_20200620_1633'),
]
operations = [
migrations.AddField(
model_name='quota',
name='release_after_exit',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.6 on 2020-06-28 19:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0155_quota_release_after_exit'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='override_tax_rate',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
]

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

@@ -375,6 +375,7 @@ class Event(EventMixin, LoggedModel):
self.settings.invoice_include_expire_date = True
self.settings.ticketoutput_pdf__enabled = True
self.settings.ticketoutput_passbook__enabled = True
self.settings.event_list_type = 'calendar'
@property
def social_image(self):
@@ -414,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.")
@@ -501,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
@@ -882,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()
@@ -1060,21 +1064,35 @@ class SubEvent(EventMixin, LoggedModel):
return self.event.settings
@cached_property
def item_price_overrides(self):
def item_overrides(self):
from .items import SubEventItem
return {
si.item_id: si.price
for si in SubEventItem.objects.filter(subevent=self, price__isnull=False)
si.item_id: si
for si in SubEventItem.objects.filter(subevent=self)
}
@cached_property
def var_price_overrides(self):
def var_overrides(self):
from .items import SubEventItemVariation
return {
si.variation_id: si
for si in SubEventItemVariation.objects.filter(subevent=self)
}
@property
def item_price_overrides(self):
return {
si.item_id: si.price
for si in self.item_overrides.values() if si.price is not None
}
@property
def var_price_overrides(self):
return {
si.variation_id: si.price
for si in SubEventItemVariation.objects.filter(subevent=self, price__isnull=False)
for si in self.var_overrides.values() if si.price is not None
}
@property
@@ -1096,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:
@@ -1168,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

@@ -9,10 +9,10 @@ from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import pgettext
from django_countries.fields import CountryField
from django_scopes import ScopedManager
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.helpers.countries import FastCountryField
def invoice_filename(instance, filename: str) -> str:
@@ -84,7 +84,7 @@ class Invoice(models.Model):
invoice_from_name = models.CharField(max_length=190, null=True)
invoice_from_zipcode = models.CharField(max_length=190, null=True)
invoice_from_city = models.CharField(max_length=190, null=True)
invoice_from_country = CountryField(null=True)
invoice_from_country = FastCountryField(null=True)
invoice_from_tax_id = models.CharField(max_length=190, null=True)
invoice_from_vat_id = models.CharField(max_length=190, null=True)
invoice_to = models.TextField()
@@ -94,7 +94,7 @@ class Invoice(models.Model):
invoice_to_zipcode = models.CharField(max_length=190, null=True)
invoice_to_city = models.TextField(null=True)
invoice_to_state = models.CharField(max_length=190, null=True)
invoice_to_country = CountryField(null=True)
invoice_to_country = FastCountryField(null=True)
invoice_to_vat_id = models.TextField(null=True)
invoice_to_beneficiary = models.TextField(null=True)
date = models.DateField(default=today)
@@ -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,6 +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, verbose_name=_('Disable product for this date'))
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -145,6 +146,7 @@ class SubEventItemVariation(models.Model):
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE)
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
disabled = models.BooleanField(default=False)
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -376,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(
@@ -410,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'),
@@ -446,24 +449,32 @@ class Item(LoggedModel):
return self.event.settings.show_quota_left
return self.show_quota_left
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
price = price if price is not None else self.default_price
if not self.tax_rule:
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.tax_rule.tax(price, base_price_is=base_price_is,
currency=currency or self.event.currency)
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
override_tax_rate=override_tax_rate, currency=currency or self.event.currency)
if include_bundled:
for b in self.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
if b.bundled_variation:
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
invoice_address=invoice_address,
currency=currency)
else:
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
compare_price = self.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
bprice = b.bundled_item.tax(b.designated_price * b.count,
invoice_address=invoice_address,
base_price_is='gross',
currency=currency)
compare_price = self.tax_rule.tax(b.designated_price * b.count,
override_tax_rate=override_tax_rate,
invoice_address=invoice_address,
currency=currency)
t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!"
@@ -671,23 +682,31 @@ class ItemVariation(models.Model):
def price(self):
return self.default_price if self.default_price is not None else self.item.default_price
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False, override_tax_rate=None,
invoice_address=None):
price = price if price is not None else self.price
if not self.item.tax_rule:
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency,
override_tax_rate=override_tax_rate,
invoice_address=invoice_address)
if include_bundled:
for b in self.item.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.item.tax_rule_id:
if b.bundled_variation:
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
currency=currency,
invoice_address=invoice_address)
else:
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross',
currency=currency,
invoice_address=invoice_address)
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross',
currency=currency, invoice_address=invoice_address)
t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!"
@@ -822,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")
@@ -1333,6 +1356,16 @@ class Quota(LoggedModel):
)
closed = models.BooleanField(default=False)
release_after_exit = models.BooleanField(
verbose_name=_('Allow to sell more tickets once people have checked out'),
help_text=_('With this option, quota will be released as soon as people are scanned at an exit of your event. '
'This will only happen if they have been scanned both at an entry and at an exit and the exit '
'is the more recent scan. It does not matter which check-in list either of the scans was on, '
'but check-in lists are ignored if they are set to "Allow re-entering after an exit scan" to '
'prevent accidental overbooking.'),
default=False,
)
objects = ScopedManager(organizer='event__organizer')
class Meta:

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

@@ -27,7 +27,7 @@ from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country, CountryField
from django_countries.fields import Country
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
@@ -44,7 +44,7 @@ from pretix.base.services.locking import NoLockManager
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import order_gracefully_delete
from ...helpers.countries import CachedCountries
from ...helpers.countries import CachedCountries, FastCountryField
from .base import LockModel, LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota
@@ -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(
@@ -502,10 +549,16 @@ class Order(LockModel, LoggedModel):
# Algorithm to choose which payments are to be refunded to create the least hassle
payments = payments or self.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED)
for p in payments:
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
p.propose_refund = Decimal('0.00')
p.available_amount = p.amount - p.refunded_amount
if p.payment_provider:
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
p.propose_refund = Decimal('0.00')
p.available_amount = p.amount - p.refunded_amount
else:
p.full_refund_possible = False
p.partial_refund_possible = False
p.propose_refund = Decimal('0.00')
p.available_amount = Decimal('0.00')
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
to_refund = amount
@@ -789,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
from pretix.base.services.mail import (
SendMailException, TolerantDict, mail, render_mail,
)
if not self.email:
return
@@ -804,6 +859,7 @@ class Order(LockModel, LoggedModel):
try:
email_content = render_mail(template, context)
subject = subject.format_map(TolerantDict(context))
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
@@ -1070,7 +1126,7 @@ class AbstractPosition(models.Model):
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
country = CountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
country = FastCountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
class Meta:
@@ -1186,7 +1242,7 @@ class AbstractPosition(models.Model):
self.attendee_name,
self.company,
self.street,
self.zipcode + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name
]
lines = [r.strip() for r in lines if r]
@@ -1371,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)
@@ -1441,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():
@@ -1794,7 +1852,10 @@ class OrderFee(models.Model):
self.fee_type, self.value
)
def _calculate_tax(self):
def _calculate_tax(self, tax_rule=None):
if tax_rule:
self.tax_rule = tax_rule
try:
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
@@ -1804,18 +1865,17 @@ class OrderFee(models.Model):
self.tax_rule = self.order.event.settings.tax_rate_default
if self.tax_rule:
if self.tax_rule.tax_applicable(ia):
tax = self.tax_rule.tax(self.value, base_price_is='gross')
self.tax_rate = tax.rate
self.tax_value = tax.tax
else:
self.tax_value = Decimal('0.00')
self.tax_rate = Decimal('0.00')
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia)
self.tax_rate = tax.rate
self.tax_value = tax.tax
else:
self.tax_value = Decimal('0.00')
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()
@@ -1950,20 +2010,16 @@ class OrderPosition(AbstractPosition):
self.item.id, self.variation.id if self.variation else 0, self.order_id
)
def _calculate_tax(self):
self.tax_rule = self.item.tax_rule
def _calculate_tax(self, tax_rule=None):
self.tax_rule = tax_rule or self.item.tax_rule
try:
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
if self.tax_rule:
if self.tax_rule.tax_applicable(ia):
tax = self.tax_rule.tax(self.price, base_price_is='gross')
self.tax_rate = tax.rate
self.tax_value = tax.tax
else:
self.tax_value = Decimal('0.00')
self.tax_rate = Decimal('0.00')
tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross')
self.tax_rate = tax.rate
self.tax_value = tax.tax
else:
self.tax_value = Decimal('0.00')
self.tax_rate = Decimal('0.00')
@@ -2021,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
@@ -2102,6 +2160,10 @@ class CartPosition(AbstractPosition):
includes_tax = models.BooleanField(
default=True
)
override_tax_rate = models.DecimalField(
max_digits=10, decimal_places=2,
null=True, blank=True
)
is_bundled = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
@@ -2118,6 +2180,8 @@ class CartPosition(AbstractPosition):
@property
def tax_rate(self):
if self.includes_tax:
if self.override_tax_rate is not None:
return self.override_tax_rate
return self.item.tax(self.price, base_price_is='gross').rate
else:
return Decimal('0.00')
@@ -2125,7 +2189,7 @@ class CartPosition(AbstractPosition):
@property
def tax_value(self):
if self.includes_tax:
return self.item.tax(self.price, base_price_is='gross').tax
return self.item.tax(self.price, override_tax_rate=self.override_tax_rate, base_price_is='gross').tax
else:
return Decimal('0.00')
@@ -2141,8 +2205,8 @@ class InvoiceAddress(models.Model):
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
countries=CachedCountries)
country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
countries=CachedCountries)
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True)
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
help_text=_('Only for business customers within the EU.'))

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

@@ -5,12 +5,12 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from i18nfield.fields import I18nCharField
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
from pretix.base.templatetags.money import money_filter
from pretix.helpers.countries import FastCountryField
class TaxedPrice:
@@ -116,7 +116,7 @@ class TaxRule(LoggedModel):
"ID. Only enable this option after consulting a tax counsel. No warranty given for correct tax "
"calculation. USE AT YOUR OWN RISK.")
)
home_country = CountryField(
home_country = FastCountryField(
verbose_name=_('Merchant country'),
blank=True,
help_text=_('Your country of residence. This is the country the EU reverse charge rule will not apply in, '
@@ -164,16 +164,39 @@ class TaxRule(LoggedModel):
def has_custom_rules(self):
return self.custom_rules and self.custom_rules != '[]'
def tax(self, base_price, base_price_is='auto', currency=None):
def tax_rate_for(self, invoice_address):
if not self._tax_applicable(invoice_address):
return Decimal('0.00')
if self.has_custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
return Decimal(rule.get('rate'))
return Decimal(self.rate)
def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None,
subtract_from_gross=Decimal('0.00')):
from .event import Event
try:
currency = currency or self.event.currency
except Event.DoesNotExist:
pass
if self.rate == Decimal('0.00'):
rate = Decimal(self.rate)
if override_tax_rate is not None:
rate = override_tax_rate
elif invoice_address:
adjust_rate = self.tax_rate_for(invoice_address)
if adjust_rate != rate:
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.net
base_price_is = 'net'
subtract_from_gross = Decimal('0.00')
rate = adjust_rate
if rate == Decimal('0.00'):
return TaxedPrice(
net=base_price, gross=base_price, tax=Decimal('0.00'),
rate=self.rate, name=self.name
net=base_price - subtract_from_gross, gross=base_price - subtract_from_gross, tax=Decimal('0.00'),
rate=rate, name=self.name
)
if base_price_is == 'auto':
@@ -183,19 +206,27 @@ class TaxRule(LoggedModel):
base_price_is = 'net'
if base_price_is == 'gross':
gross = base_price
net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))),
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':
net = base_price
gross = round_decimal((net * (1 + self.rate / 100)),
currency)
gross = round_decimal((net * (1 + rate / 100)), currency)
if subtract_from_gross:
gross -= subtract_from_gross
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
currency)
else:
raise ValueError('Unknown base price type: {}'.format(base_price_is))
return TaxedPrice(
net=net, gross=gross, tax=gross - net,
rate=self.rate, name=self.name
rate=rate, name=self.name
)
@property
@@ -243,7 +274,7 @@ class TaxRule(LoggedModel):
return False
def tax_applicable(self, invoice_address):
def _tax_applicable(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule.get('action', 'vat') == 'vat'

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:
"""
@@ -306,7 +317,7 @@ class BasePaymentProvider:
initial=['web'],
widget=forms.CheckboxSelectMultiple,
help_text=_(
'Only allow the usage of this payment provider in the following sales channels'),
'Only allow the usage of this payment provider in the selected sales channels.'),
)),
('_hidden',
forms.BooleanField(
@@ -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.
@@ -902,7 +915,7 @@ class ManualPayment(BasePaymentProvider):
label=_('Payment process description in order confirmation emails'),
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
'mails. It should instruct the user on how to proceed with the payment. You can use '
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
'the placeholders {order}, {total}, {currency} and {total_with_currency}.'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
@@ -910,7 +923,7 @@ class ManualPayment(BasePaymentProvider):
label=_('Payment process description for pending orders'),
help_text=_('This text will be shown on the order confirmation page for pending orders. '
'It should instruct the user on how to proceed with the payment. You can use '
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
'the placeholders {order}, {total}, {currency} and {total_with_currency}.'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
@@ -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,
@@ -1199,7 +1215,7 @@ class GiftCardPayment(BasePaymentProvider):
if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard
raise PaymentException(_("This gift card is not accepted by this event organizer."))
if payment.amount > gc.value: # noqa - just a safeguard
raise PaymentException(_("This gift card was used in the meantime. Please try again"))
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
if gc.expires and gc.expires < now(): # noqa - just a safeguard
messages.error(request, _("This gift card is no longer valid."))
return

View File

@@ -114,11 +114,10 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Sample company"),
"evaluate": lambda op, order, ev: op.company or (op.addon_to.company if op.addon_to else '')
}),
('shipping_address', {
('attendee_address', {
'label': _('Full attendee address'),
'editor_sample': _('John Doe\nSample company\nSesame Street 42\n12345 Any City\nAtlantis'),
'evaluate': lambda op, order, event: order.shipping_address.format() if hasattr(
order, 'shipping_address') else ''
'evaluate': lambda op, order, event: op.address_format()
}),
("attendee_country", {
"label": _("Attendee country"),
@@ -493,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)
@@ -545,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]
@@ -253,6 +256,12 @@ class CartManager:
if self._sales_channel not in op.item.sales_channels:
raise CartError(error_messages['unavailable'])
if op.subevent and op.item.pk in op.subevent.item_overrides and op.subevent.item_overrides[op.item.pk].disabled:
raise CartError(error_messages['not_for_sale'])
if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and op.subevent.var_overrides[op.variation.pk].disabled:
raise CartError(error_messages['not_for_sale'])
if op.item.has_variations and not op.variation:
raise CartError(error_messages['not_for_sale'])
@@ -321,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',
@@ -342,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()
@@ -599,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 = []
@@ -618,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:
@@ -649,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
)
@@ -679,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(
@@ -690,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(
@@ -701,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
@@ -901,6 +931,7 @@ class CartManager:
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat,
override_tax_rate=op.price.rate,
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
)
if self.event.settings.attendee_names_asked:
@@ -934,7 +965,7 @@ class CartManager:
new_cart_positions.append(CartPosition(
event=self.event, item=b.item, variation=b.variation,
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=None, addon_to=cp,
voucher=None, addon_to=cp, override_tax_rate=b.price.rate,
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
))
@@ -1026,19 +1057,15 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
for pos in positions:
if not pos.item.tax_rule:
continue
charge_tax = pos.item.tax_rule.tax_applicable(invoice_address)
if pos.includes_tax and not charge_tax:
price = pos.item.tax(pos.price, base_price_is='gross').net
totaldiff += price - pos.price
pos.price = price
pos.includes_tax = False
pos.save(update_fields=['price', 'includes_tax'])
elif charge_tax and not pos.includes_tax:
price = pos.item.tax(pos.price, base_price_is='net').gross
totaldiff += price - pos.price
pos.price = price
pos.includes_tax = True
pos.save(update_fields=['price', 'includes_tax'])
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
if pos.tax_rate != rate:
current_net = pos.price - pos.tax_value
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
pos.price = new_gross
pos.includes_tax = rate != Decimal('0.00')
pos.override_tax_rate = rate
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
return totaldiff
@@ -1086,23 +1113,14 @@ def get_fees(event, request, total, invoice_address, provider, positions):
if payment_fee:
payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero()
if payment_fee_tax_rule.tax_applicable(invoice_address):
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross')
fees.append(OrderFee(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=payment_fee,
tax_rate=payment_fee_tax.rate,
tax_value=payment_fee_tax.tax,
tax_rule=payment_fee_tax_rule
))
else:
fees.append(OrderFee(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=payment_fee,
tax_rate=Decimal('0.00'),
tax_value=Decimal('0.00'),
tax_rule=payment_fee_tax_rule
))
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
fees.append(OrderFee(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=payment_fee,
tax_rate=payment_fee_tax.rate,
tax_value=payment_fee_tax.tax,
tax_rule=payment_fee_tax_rule
))
return fees
@@ -1227,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=30)):
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=30)):
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

@@ -5,9 +5,15 @@ from django.utils.timezone import override
from django.utils.translation import gettext
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.signals import register_data_exporters
from pretix.base.models import (
CachedFile, Event, Organizer, User, cachedfile_name,
)
from pretix.base.services.tasks import (
ProfiledEventTask, ProfiledOrganizerUserTask,
)
from pretix.base.signals import (
register_data_exporters, register_multievent_data_exporters,
)
from pretix.celery_app import app
@@ -15,13 +21,52 @@ 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:
raise ExportError(
gettext('Your export did not contain any data.')
)
file.filename, file.type, data = d
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
file.save()
return file.pk
@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')
events = allowed_events.filter(pk__in=form_data.get('events'))
responses = register_multievent_data_exporters.send(organizer)
for receiver, response in responses:
if not response:
continue
ex = response(events, set_progress)
if ex.identifier == provider:
d = ex.render(form_data)
if d is None:

View File

@@ -24,7 +24,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
)
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.models.tax import EU_COUNTRIES, EU_CURRENCIES
from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import invoice_line_text, periodic_task
@@ -105,7 +105,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
cc = str(ia.country)
if cc in EU_CURRENCIES and EU_CURRENCIES[cc] != invoice.event.currency:
if cc in EU_CURRENCIES and EU_CURRENCIES[cc] != invoice.event.currency and invoice.event.settings.invoice_eu_currencies:
invoice.foreign_currency_display = EU_CURRENCIES[cc]
if settings.FETCH_ECB_RATES:
@@ -181,11 +181,17 @@ def build_invoice(invoice: Invoice) -> Invoice:
if reverse_charge:
if invoice.additional_text:
invoice.additional_text += "<br /><br />"
invoice.additional_text += pgettext(
"invoice",
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
"rests with the service recipient."
)
if str(invoice.invoice_to_country) in EU_COUNTRIES:
invoice.additional_text += pgettext(
"invoice",
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
"rests with the service recipient."
)
else:
invoice.additional_text += pgettext(
"invoice",
"VAT liability rests with the service recipient."
)
invoice.reverse_charge = True
invoice.save()
@@ -234,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))
@@ -283,50 +286,51 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
except Order.DoesNotExist:
order = None
else:
if position:
try:
position = order.positions.get(pk=position)
except OrderPosition.DoesNotExist:
attach_tickets = False
if attach_tickets:
args = []
attach_size = 0
for name, ct in get_tickets_for_order(order, base_position=position):
content = ct.file.read()
args.append((name, content, ct.type))
attach_size += len(content)
with language(order.locale):
if position:
try:
position = order.positions.get(pk=position)
except OrderPosition.DoesNotExist:
attach_tickets = False
if attach_tickets:
args = []
attach_size = 0
for name, ct in get_tickets_for_order(order, base_position=position):
content = ct.file.read()
args.append((name, content, ct.type))
attach_size += len(content)
if attach_size < 4 * 1024 * 1024:
# Do not attach more than 4MB, it will bounce way to often.
for a in args:
try:
email.attach(*a)
except:
pass
else:
order.log_action(
'pretix.event.order.email.attachments.skipped',
data={
'subject': 'Attachments skipped',
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
'recipient': '',
'invoices': [],
}
)
if attach_ical:
ical_events = set()
if event.has_subevents:
if position:
ical_events.add(position.subevent)
if attach_size < 4 * 1024 * 1024:
# Do not attach more than 4MB, it will bounce way to often.
for a in args:
try:
email.attach(*a)
except:
pass
else:
for p in order.positions.all():
ical_events.add(p.subevent)
else:
ical_events.add(order.event)
order.log_action(
'pretix.event.order.email.attachments.skipped',
data={
'subject': 'Attachments skipped',
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
'recipient': '',
'invoices': [],
}
)
if attach_ical:
ical_events = set()
if event.has_subevents:
if position:
ical_events.add(position.subevent)
else:
for p in order.positions.all():
ical_events.add(p.subevent)
else:
ical_events.add(order.event)
for i, e in enumerate(ical_events):
cal = get_ical([e])
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
for i, e in enumerate(ical_events):
cal = get_ical([e])
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
@@ -413,7 +417,7 @@ def replace_images_with_cid_paths(body_html):
image['src'] = "cid:%s" % cid_id
return email.prettify(), cid_images
return str(email), cid_images
else:
return body_html, []

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
@@ -34,7 +36,7 @@ from pretix.base.models.orders import (
generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TaxedPrice, TaxRule
from pretix.base.models.tax import TaxRule
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services import tickets
@@ -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
@@ -641,6 +644,16 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if cp.subevent and cp.item.pk in cp.subevent.item_overrides and cp.subevent.item_overrides[cp.item.pk].disabled:
err = err or error_messages['unavailable']
delete(cp)
continue
if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and cp.subevent.var_overrides[cp.variation.pk].disabled:
err = err or error_messages['unavailable']
delete(cp)
continue
if cp.voucher:
if cp.voucher.valid_until and cp.voucher.valid_until < now_dt:
err = err or error_messages['voucher_expired']
@@ -879,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
)
@@ -923,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
@@ -950,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
@@ -976,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
@@ -1046,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
@@ -1121,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')
@@ -1150,6 +1172,7 @@ class OrderChangeManager:
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
SplitOperation = namedtuple('SplitOperation', ('position',))
@@ -1224,6 +1247,7 @@ class OrderChangeManager:
self._quotadiff.update(new_quotas)
self._quotadiff.subtract(position.quotas)
self._operations.append(self.SubeventOperation(position, subevent))
self._invoice_dirty = True
def change_item_and_subevent(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation],
subevent: SubEvent):
@@ -1245,12 +1269,14 @@ class OrderChangeManager:
self._quotadiff.subtract(position.quotas)
self._operations.append(self.ItemOperation(position, item, variation))
self._operations.append(self.SubeventOperation(position, subevent))
self._invoice_dirty = True
def regenerate_secret(self, position: OrderPosition):
self._operations.append(self.RegenerateSecretOperation(position))
def change_price(self, position: OrderPosition, price: Decimal):
price = position.item.tax(price, base_price_is='gross')
tax_rule = self._current_tax_rules().get(position.pk, position.tax_rule) or TaxRule.zero()
price = tax_rule.tax(price, base_price_is='gross')
if position.issued_gift_cards.exists():
raise OrderError(self.error_messages['gift_card_change'])
@@ -1262,27 +1288,42 @@ class OrderChangeManager:
self._operations.append(self.PriceOperation(position, price))
def recalculate_taxes(self):
def change_tax_rule(self, position_or_fee, tax_rule: TaxRule):
self._operations.append(self.TaxRuleOperation(position_or_fee, tax_rule))
self._invoice_dirty = True
def _current_tax_rules(self):
tax_rules = {}
for p in self._operations:
if isinstance(p, self.TaxRuleOperation):
tax_rules[p.position.pk] = p.tax_rule
elif isinstance(p, self.ItemOperation):
tax_rules[p.position.pk] = p.item.tax_rule
return tax_rules
def recalculate_taxes(self, keep='net'):
positions = self.order.positions.select_related('item', 'item__tax_rule')
ia = self._invoice_address
tax_rules = self._current_tax_rules()
for pos in positions:
if not pos.item.tax_rule:
tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
if not tax_rule:
continue
if not pos.price:
continue
charge_tax = pos.item.tax_rule.tax_applicable(ia)
if pos.tax_value and not charge_tax:
net_price = pos.price - pos.tax_value
price = TaxedPrice(gross=net_price, net=net_price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
if price.gross != pos.price:
self._totaldiff += price.gross - pos.price
self._operations.append(self.PriceOperation(pos, price))
elif charge_tax and not pos.tax_value:
price = pos.item.tax(pos.price, base_price_is='net')
if price.gross != pos.price:
self._totaldiff += price.gross - pos.price
self._operations.append(self.PriceOperation(pos, price))
new_rate = tax_rule.tax_rate_for(ia)
# We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself
if new_rate != pos.tax_rate:
if keep == 'net':
new_tax = tax_rule.tax(pos.price - pos.tax_value, base_price_is='net', currency=self.event.currency,
override_tax_rate=new_rate)
else:
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
override_tax_rate=new_rate)
self._totaldiff += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax))
def cancel_fee(self, fee: OrderFee):
self._totaldiff -= fee.value
@@ -1328,10 +1369,7 @@ class OrderChangeManager:
if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
if item.tax_rule and item.tax_rule.tax_applicable(self._invoice_address):
price = item.tax(price, base_price_is='gross')
else:
price = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address)
if price is None:
raise OrderError(self.error_messages['product_invalid'])
@@ -1344,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'])
@@ -1582,7 +1620,26 @@ class OrderChangeManager:
'new_price': op.price.gross
})
op.position.price = op.price.gross
op.position._calculate_tax()
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.save()
elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition):
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'addon_to': op.position.addon_to_id,
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rate else None,
'new_taxrule': op.tax_rule.pk
})
elif isinstance(op.position, OrderFee):
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
'fee': op.position.pk,
'fee_type': op.position.fee_type,
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rate else None,
'new_taxrule': op.tax_rule.pk
})
op.position._calculate_tax(op.tax_rule)
op.position.save()
elif isinstance(op, self.CancelFeeOperation):
self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={
@@ -1824,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

@@ -13,7 +13,7 @@ def get_price(item: Item, variation: ItemVariation = None,
subevent: SubEvent = None, custom_price_is_net: bool = False,
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
max_discount: Decimal = None) -> TaxedPrice:
max_discount: Decimal = None, tax_rule=None) -> TaxedPrice:
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
@@ -35,7 +35,9 @@ def get_price(item: Item, variation: ItemVariation = None,
if voucher:
price = voucher.calculate_price(price, max_discount=max_discount)
if item.tax_rule:
if tax_rule is not None:
tax_rule = tax_rule
elif item.tax_rule:
tax_rule = item.tax_rule
else:
tax_rule = TaxRule(
@@ -44,33 +46,30 @@ def get_price(item: Item, variation: ItemVariation = None,
price_includes_tax=True,
eu_reverse_charge=False,
)
price = tax_rule.tax(price)
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')
price = tax_rule.tax(custom_price, base_price_is='net', invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(custom_price, base_price_is='gross')
if item.free_price and custom_price is not None and custom_price != "":
price = tax_rule.tax(custom_price, base_price_is='gross', invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
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')
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')
if bundled_sum:
price = price - TaxedPrice(net=bundled_sum, gross=bundled_sum, rate=0, tax=0, name='')
if price.gross < Decimal('0.00'):
return TAXED_ZERO
if invoice_address and not tax_rule.tax_applicable(invoice_address):
price.tax = Decimal('0.00')
price.rate = Decimal('0.00')
price.gross = price.net
price.name = ''
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,19 +1,24 @@
import sys
from collections import Counter, defaultdict
from datetime import timedelta
from itertools import zip_longest
from django.conf import settings
from django.db.models import Count, F, Func, Max, Q, Sum
from django.db import OperationalError, models
from django.db.models import (
Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When,
)
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import (
CartPosition, Event, LogEntry, Order, OrderPosition, Quota, Voucher,
WaitingListEntry,
CartPosition, Checkin, Event, LogEntry, Order, OrderPosition, Quota,
Voucher, WaitingListEntry,
)
from pretix.celery_app import app
from ...helpers.periodic import minimum_interval
from ..signals import periodic_task, quota_availability
@@ -74,6 +79,7 @@ class QuotaAvailability:
self.results = {}
self.count_paid_orders = defaultdict(int)
self.count_pending_orders = defaultdict(int)
self.count_exited_orders = defaultdict(int)
self.count_vouchers = defaultdict(int)
self.count_waitinglist = defaultdict(int)
self.count_cart = defaultdict(int)
@@ -85,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:
@@ -99,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}
@@ -214,25 +225,63 @@ class QuotaAvailability:
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
) | Q(
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas})
).order_by().values('order__status', 'item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
for line in sorted(op_lookup, key=lambda li: li['order__status'], reverse=True): # p before n
).order_by()
if any(q.release_after_exit for q in quotas):
op_lookup = op_lookup.annotate(
last_entry=Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
list__allow_entry_after_exit=False,
type=Checkin.TYPE_ENTRY,
).order_by().values('position_id').annotate(
m=Max('datetime')
).values('m')
),
last_exit=Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
list__allow_entry_after_exit=False,
type=Checkin.TYPE_EXIT,
).order_by().values('position_id').annotate(
m=Max('datetime')
).values('m')
),
).annotate(
is_exited=Case(
When(
Q(last_entry__isnull=False) & Q(last_exit__isnull=False) & Q(last_exit__gt=F('last_entry')),
then=Value(1, output_field=models.IntegerField()),
),
default=Value(0, output_field=models.IntegerField()),
output_field=models.IntegerField(),
),
)
else:
op_lookup = op_lookup.annotate(
is_exited=Value(0, output_field=models.IntegerField())
)
op_lookup = op_lookup.values('order__status', 'item_id', 'subevent_id', 'variation_id', 'is_exited').annotate(c=Count('*'))
for line in sorted(op_lookup, key=lambda li: (int(li['is_exited']), li['order__status']), reverse=True): # p before n, exited before non-exited
if line['variation_id']:
qs = self._var_to_quotas[line['variation_id']]
else:
qs = self._item_to_quotas[line['item_id']]
for q in qs:
if q.subevent_id == line['subevent_id']:
size_left[q] -= line['c']
if line['order__status'] == Order.STATUS_PAID:
self.count_paid_orders[q] += line['c']
q.cached_availability_paid_orders = 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
else:
self.results[q] = Quota.AVAILABILITY_ORDERED, 0
if q.release_after_exit and line['is_exited']:
self.count_exited_orders[q] += line['c']
else:
size_left[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
else:
self.results[q] = Quota.AVAILABILITY_ORDERED, 0
def _compute_vouchers(self, quotas, q_items, q_vars, size_left, now_dt):
events = {q.event_id for q in quotas}
@@ -355,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():
@@ -382,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

@@ -19,7 +19,7 @@ from django_scopes import scope, scopes_disabled
from pretix.base.metrics import (
pretix_task_duration_seconds, pretix_task_runs_total,
)
from pretix.base.models import Event
from pretix.base.models import Event, Organizer, User
from pretix.celery_app import app
@@ -88,10 +88,30 @@ class EventTask(app.Task):
return ret
class OrganizerUserTask(app.Task):
def __call__(self, *args, **kwargs):
organizer_id = kwargs['organizer']
with scopes_disabled():
organizer = Organizer.objects.get(pk=organizer_id)
kwargs['organizer'] = organizer
user_id = kwargs['user']
user = User.objects.get(pk=user_id)
kwargs['user'] = user
with scope(organizer=organizer):
ret = super().__call__(*args, **kwargs)
return ret
class ProfiledEventTask(ProfiledTask, EventTask):
pass
class ProfiledOrganizerUserTask(ProfiledTask, OrganizerUserTask):
pass
class TransactionAwareTask(ProfiledTask):
"""
Task class which is aware of django db transactions and only executes tasks

View File

@@ -163,10 +163,20 @@ def get_tickets_for_order(order, base_position=None):
if ct.type == 'text/uri-list':
continue
if pos.subevent:
# Subevent date in filename improves accessibility e.g. for screen reader users
fname = "{}-{}-{}-{}-{}{}".format(
order.event.slug.upper(), order.code, pos.positionid,
pos.subevent.date_from.strftime('%Y_%m_%d'),
ct.provider, ct.extension
)
else:
fname = "{}-{}-{}-{}{}".format(
order.event.slug.upper(), order.code, pos.positionid,
ct.provider, ct.extension
)
tickets.append((
"{}-{}-{}-{}{}".format(
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
),
fname,
ct
))
except:

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
@@ -13,7 +14,6 @@ from django.db.models import Model
from django.utils.translation import (
gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy,
)
from django_countries import countries
from hierarkey.models import GlobalSettingsBase, Hierarkey
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
@@ -27,9 +27,29 @@ from pretix.base.reldate import (
SerializerRelativeDateField, SerializerRelativeDateTimeField,
)
from pretix.control.forms import MultipleLanguagesWidget, SingleLanguageWidget
from pretix.helpers.countries import CachedCountries
allcountries = list(countries)
allcountries.insert(0, ('', _('Select country')))
def country_choice_kwargs():
allcountries = list(CachedCountries())
allcountries.insert(0, ('', _('Select country')))
return {
'choices': allcountries
}
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 = {
@@ -52,10 +72,14 @@ DEFAULTS = {
'form_kwargs': dict(
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
"paid"),
"paid."),
)
},
'system_question_order': {
'default': {},
'type': dict,
},
'attendee_names_asked': {
'default': 'True',
'type': bool,
@@ -189,6 +213,16 @@ DEFAULTS = {
label=_("Show attendee names on invoices"),
)
},
'invoice_eu_currencies': {
'default': 'True',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("On invoices from one EU country into another EU country with a different currency, print the "
"tax amounts in both currencies if possible"),
)
},
'invoice_address_required': {
'default': 'False',
'form_class': forms.BooleanField,
@@ -290,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,
@@ -481,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(
@@ -492,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.")
)
@@ -566,13 +610,8 @@ DEFAULTS = {
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': dict(
choices=allcountries,
),
'form_kwargs': dict(
choices=allcountries,
label=_("Country"),
)
'serializer_kwargs': country_choice_kwargs,
'form_kwargs': country_choice_kwargs,
},
'invoice_address_from_tax_id': {
'default': '',
@@ -808,7 +847,7 @@ DEFAULTS = {
'form_class': forms.IntegerField,
'form_kwargs': dict(
label=_("Waiting list response time"),
min_value=6,
min_value=1,
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
"number of hours until it expires and can be re-assigned to the next person on the list."),
widget=forms.NumberInput(),
@@ -896,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': {
@@ -911,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,
@@ -1063,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',
@@ -1126,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': ""
@@ -1337,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"""))
},
@@ -1648,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
@@ -1867,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

@@ -34,11 +34,11 @@ def shred_constraints(event: Event):
max_fromto=Greatest(Max('date_to'), Max('date_from'))
)
max_date = max_date['max_fromto'] or max_date['max_to'] or max_date['max_from']
if max_date is not None and max_date > now() - timedelta(days=60):
return _('Your event needs to be over for at least 60 days to use this feature.')
if max_date is not None and max_date > now() - timedelta(days=30):
return _('Your event needs to be over for at least 30 days to use this feature.')
else:
if (event.date_to or event.date_from) > now() - timedelta(days=60):
return _('Your event needs to be over for at least 60 days to use this feature.')
if (event.date_to or event.date_from) > now() - timedelta(days=30):
return _('Your event needs to be over for at least 30 days to use this feature.')
if event.live:
return _('Your ticket shop needs to be offline to use this feature.')
return None

View File

@@ -268,6 +268,16 @@ subclass of pretix.base.exporter.BaseExporter
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_multievent_data_exporters = django.dispatch.Signal(
providing_args=["event"]
)
"""
This signal is sent out to get all known data exporters, which support exporting data for
multiple events. Receivers should return a subclass of pretix.base.exporter.BaseExporter
The ``sender`` keyword argument will contain an organizer.
"""
validate_order = EventPluginSignal(
providing_args=["payment_provider", "positions", "email", "locale", "invoice_address",
"meta_info"]
@@ -299,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

@@ -109,15 +109,25 @@ def markdown_compile_email(source):
))
class SnippetExtension(markdown.extensions.Extension):
def extendMarkdown(self, md, *args, **kwargs):
del md.parser.blockprocessors['olist']
del md.parser.blockprocessors['ulist']
del md.parser.blockprocessors['quote']
def markdown_compile(source, snippet=False):
tags = ALLOWED_TAGS_SNIPPET if snippet else ALLOWED_TAGS
exts = [
'markdown.extensions.sane_lists',
'markdown.extensions.nl2br'
]
if snippet:
exts.append(SnippetExtension())
return bleach.clean(
markdown.markdown(
source,
extensions=[
'markdown.extensions.sane_lists',
'markdown.extensions.nl2br'
]
extensions=exts
),
strip=snippet,
tags=tags,

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

@@ -47,11 +47,6 @@ class EventWizardFoundationForm(forms.Form):
)
has_subevents = forms.BooleanField(
label=_("This is an event series"),
help_text=_('Only recommended for advanced users. If this feature is enabled, this will not only be a '
'single event but a series of very similar events that are handled within a single shop. '
'The single events inside the series can only differ in date, time, location, prices and '
'quotas, but not in other settings, and buying tickets across multiple of these events at '
'the same time is possible. You cannot change this setting for this event later.'),
required=False,
)
@@ -159,6 +154,7 @@ class EventWizardBasicsForm(I18nModelForm):
if self.has_subevents:
del self.fields['presale_start']
del self.fields['presale_end']
del self.fields['date_to']
if self.has_control_rights(self.user, self.organizer):
del self.fields['team']
@@ -522,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',
@@ -539,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']),
@@ -579,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):
@@ -678,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',
@@ -690,7 +684,7 @@ class InvoiceSettingsForm(SettingsForm):
'invoice_introductory_text',
'invoice_additional_text',
'invoice_footer_text',
'invoice_eu_currencies',
]
invoice_generate_sales_channels = forms.MultipleChoiceField(
@@ -750,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',
@@ -758,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"),
@@ -909,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(
@@ -958,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'],
@@ -1090,6 +1118,11 @@ class TaxRuleLineForm(forms.Form):
('no', _('No VAT')),
],
)
rate = forms.DecimalField(
label=_('Deviating tax rate'),
max_digits=10, decimal_places=2,
required=False
)
TaxRuleLineFormSet = formset_factory(
@@ -1311,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
)
@@ -349,16 +357,34 @@ class OrderSearchFilterForm(OrderFilterForm):
)
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
if request.user.has_active_staff_session(request.session.session_key):
if self.request.user.has_active_staff_session(self.request.session.session_key):
self.fields['organizer'].queryset = Organizer.objects.all()
else:
self.fields['organizer'].queryset = Organizer.objects.filter(
pk__in=request.user.teams.values_list('organizer', flat=True)
pk__in=self.request.user.teams.values_list('organizer', flat=True)
)
self.fields['provider'].choices += get_all_payment_providers()
seen = set()
for p in self.meta_properties.all():
if p.name in seen:
continue
seen.add(p.name)
self.fields['meta_{}'.format(p.name)] = forms.CharField(
label=p.name,
required=False,
widget=forms.TextInput(
attrs={
'data-typeahead-url': reverse('control:events.meta.typeahead') + '?' + urlencode({
'property': p.name,
'organizer': ''
})
}
)
)
def filter_qs(self, qs):
fdata = self.cleaned_data
qs = super().filter_qs(qs)
@@ -366,8 +392,42 @@ class OrderSearchFilterForm(OrderFilterForm):
if fdata.get('organizer'):
qs = qs.filter(event__organizer=fdata.get('organizer'))
filters_by_property_name = {}
for i, p in enumerate(self.meta_properties):
d = fdata.get('meta_{}'.format(p.name))
if d:
emv_with_value = EventMetaValue.objects.filter(
event=OuterRef('event_id'),
property__pk=p.pk,
value=d
)
emv_with_any_value = EventMetaValue.objects.filter(
event=OuterRef('event_id'),
property__pk=p.pk,
)
qs = qs.annotate(**{'attr_{}'.format(i): Exists(emv_with_value)})
if p.name in filters_by_property_name:
filters_by_property_name[p.name] |= Q(**{'attr_{}'.format(i): True})
else:
filters_by_property_name[p.name] = Q(**{'attr_{}'.format(i): True})
if p.default == d:
qs = qs.annotate(**{'attr_{}_any'.format(i): Exists(emv_with_any_value)})
filters_by_property_name[p.name] |= Q(**{
'attr_{}_any'.format(i): False, 'event__organizer_id': p.organizer_id
})
for f in filters_by_property_name.values():
qs = qs.filter(f)
return qs
@cached_property
def meta_properties(self):
# We ignore superuser permissions here. This is intentional we do not want to show super
# users a form with all meta properties ever assigned.
return EventMetaProperty.objects.filter(
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True)
)
class SubEventFilterForm(FilterForm):
orders = {
@@ -671,10 +731,12 @@ class EventFilterForm(FilterForm):
elif fdata.get('status') == 'running':
qs = qs.filter(
live=True
).annotate(
p_end=Coalesce(F('presale_end'), F('date_to'), F('date_from'))
).filter(
Q(presale_start__isnull=True) | Q(presale_start__lte=now())
).filter(
Q(presale_end__isnull=True) | Q(presale_end__gte=now())
Q(p_end__gte=now())
)
elif fdata.get('status') == 'notlive':
qs = qs.filter(live=False)
@@ -783,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')),
@@ -813,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)
@@ -829,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)

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