Compare commits

...

187 Commits

Author SHA1 Message Date
Raphael Michel
798fdbf25b Bump to 4.7.0 2022-02-25 12:31:44 +01:00
Raphael Michel
1718a537e6 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4624 of 4624 strings)

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

powered by weblate
2022-02-25 12:30:40 +01:00
Raphael Michel
b4d8936b78 Translations: Update German
Currently translated at 100.0% (4624 of 4624 strings)

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

powered by weblate
2022-02-25 12:30:40 +01:00
Raphael Michel
683bc3f6dc Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4624 of 4624 strings)

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

powered by weblate
2022-02-25 12:30:40 +01:00
Raphael Michel
b79c95f334 Translations: Update German
Currently translated at 100.0% (4624 of 4624 strings)

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

powered by weblate
2022-02-25 12:30:40 +01:00
Raphael Michel
7821ba09ec Fix #2476 -- Document resilient setup with docker and redis 2022-02-25 11:54:57 +01:00
Raphael Michel
af2600fd52 Docs: Fix reference to UserManager 2022-02-25 11:53:05 +01:00
Raphael Michel
058282a583 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-02-25 11:05:55 +01:00
Raphael Michel
16fa01ac60 Translations: Update Spanish
Currently translated at 64.6% (2990 of 4622 strings)

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

powered by weblate
2022-02-24 20:31:15 +01:00
Raphael Michel
2b5ce5364b Translations: Update Galician
Currently translated at 10.7% (495 of 4622 strings)

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

powered by weblate
2022-02-24 20:31:15 +01:00
Raphael Michel
4a93866cc3 Translations: Update Spanish
Currently translated at 64.7% (2991 of 4622 strings)

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

powered by weblate
2022-02-24 20:31:15 +01:00
Ismael Menéndez Fernández
fbc1d862a1 Translations: Update Galician
Currently translated at 10.7% (496 of 4622 strings)

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

powered by weblate
2022-02-24 20:31:15 +01:00
Tonda Pavlík
638daa2c19 Translations: Update Czech
Currently translated at 11.0% (509 of 4622 strings)

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

powered by weblate
2022-02-24 20:31:15 +01:00
Ismael Menéndez Fernández
d400a3c7d3 Translations: Update Spanish
Currently translated at 64.6% (2988 of 4622 strings)

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

powered by weblate
2022-02-24 20:31:15 +01:00
Raphael Michel
76f6947529 Voucher list: Filter case-sensitive for exact tag match 2022-02-24 18:56:45 +01:00
Raphael Michel
db7e299af1 Show voucher link even without subevent specified 2022-02-24 14:59:27 +01:00
Raphael Michel
7ed204ffc0 Workaround for PostgreSQL floating point quirk 2022-02-24 13:47:08 +01:00
Raphael Michel
14e0d9cbf4 Change restricted plugins from event-level action to org-level whitelist (#2489) 2022-02-23 15:04:16 +01:00
Raphael Michel
65fb492728 Fix crash in exporter API (PRETIXEU-650) 2022-02-23 15:03:49 +01:00
Raphael Michel
a4f64e94cc Settings form: Fix explicit unlocking, fix HTML button type 2022-02-23 13:24:47 +01:00
Raphael Michel
67ba1f81e4 OrderGo: Fix crash if invoice number matches different prefixes (PRETIXEU-64T) 2022-02-23 13:08:03 +01:00
Maico Timmerman
cc8282bef1 vouchers: allow deleting vouchers that are used on addon cartpositions (#2478) 2022-02-23 13:00:18 +01:00
Martin Gross
c7fc52cabe Doc: Improve SAML RegEx example to not match on partials. 2022-02-23 11:02:44 +01:00
Raphael Michel
4bc04de325 Update django-statici18n requirement from ==2.1.* to ==2.2.* 2022-02-23 10:35:54 +01:00
Martin Gross
9a2ecae021 Add pretix-presale-saml docs (#2468) 2022-02-23 10:32:16 +01:00
Ismael Menéndez Fernández
e55fb303c0 Translations: Update Galician
Currently translated at 9.9% (460 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Ismael Menéndez Fernández
185761e9e6 Translations: Update Galician
Currently translated at 9.6% (448 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Raphael Michel
a78cb039da Translations: Update Galician
Currently translated at 9.5% (441 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Ismael Menéndez Fernández
5f07f0e80b Translations: Update Galician
Currently translated at 9.5% (442 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Ismael Menéndez Fernández
4021b28d5f Translated on translate.pretix.eu (Galician)
Currently translated at 100.0% (172 of 172 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Ismael Menéndez Fernández
8717b1f8db Translations: Update Galician
Currently translated at 8.7% (403 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Ismael Menéndez Fernández
b789e64830 Translations: Update Galician
Currently translated at 8.0% (373 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Raphael Michel
4d595e3fd4 Translations: Update Galician
Currently translated at 6.7% (310 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Ismael Menéndez Fernández
482a9c6af7 Translations: Update Galician
Currently translated at 6.7% (310 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
MaLund13
99faa8b300 Translated on translate.pretix.eu (Swedish)
Currently translated at 83.1% (143 of 172 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
MaLund13
f0be03f93a Translations: Update Swedish
Currently translated at 21.2% (984 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Emanuele Signoretta
5f12fca88a Translations: Update Italian
Currently translated at 17.2% (795 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Emanuele Signoretta
fa88686856 Translated on translate.pretix.eu (Italian)
Currently translated at 100.0% (172 of 172 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Marco Giacopuzzi
59a6e4130e Translations: Update Italian
Currently translated at 17.1% (792 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Emanuele Signoretta
35ccc3a9af Translations: Update Italian
Currently translated at 17.1% (792 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
MaLund13
ef9e7fd92a Translations: Update Swedish
Currently translated at 21.2% (982 of 4622 strings)

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

powered by weblate
2022-02-23 10:29:35 +01:00
Raphael Michel
d7acd2b6bf Refs #2465 -- Introduce unique identifiers for authentication backends (#2466) 2022-02-23 10:16:35 +01:00
Raphael Michel
2bf5a0ce8a Update beautifulsoup4 requirement from ==4.8.* to ==4.10.* 2022-02-23 10:13:36 +01:00
Raphael Michel
7310fb3c6e Update requests requirement from ==2.26.* to ==2.27.* 2022-02-23 09:57:37 +01:00
Raphael Michel
069dd02ebc Update stripe requirement from ==2.42.* to ==2.66.* 2022-02-23 09:53:16 +01:00
Richard Schreiber
70e4b02370 Fix #2452 -- Disallow invalid values in min_per_order/max_per_order (#2453) 2022-02-23 09:48:45 +01:00
Richard Schreiber
b20797fe4b Fix details/summary marker on privacy-modal being shown twice (#2482) 2022-02-23 09:46:33 +01:00
Richard Schreiber
aee8de54ed Fix #2480 - move datetime-menu in front of map controls (#2481) 2022-02-22 13:58:54 +01:00
Raphael Michel
6d7e16c147 Fix single-event export if an exporter returns none 2022-02-21 21:07:00 +01:00
Raphael Michel
f511f5a646 Fix bug in error handling 2022-02-21 18:10:05 +01:00
Raphael Michel
6ba690932f Allow event-level exporters to return none 2022-02-21 17:58:33 +01:00
Raphael Michel
46b3e3c739 AsyncFormView: Allow to declare celery exception classes 2022-02-21 16:59:51 +01:00
Raphael Michel
3550197fc4 Fix bug in previous commit 2022-02-18 16:19:51 +01:00
Raphael Michel
db96211c7a Seating: Fix query in validate_plan_change 2022-02-18 15:21:20 +01:00
Richard Schreiber
758179f12f Add name_for_salutation to customer email placeholders (#2474) 2022-02-18 08:02:51 +01:00
Raphael Michel
98409b0a22 API: Minor robustness improvements in quota and checkinlist serializers 2022-02-17 17:37:24 +01:00
Raphael Michel
06ffa0bcd5 API: Fix creation of items with required membership types 2022-02-17 17:37:24 +01:00
Ismael Menéndez Fernández
18917769ef Translations: Update Galician
Currently translated at 4.7% (221 of 4622 strings)

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

powered by weblate
2022-02-17 14:50:47 +01:00
Miguel Magalhães
34e95bc7d2 Translations: Update Portuguese (Portugal)
Currently translated at 80.1% (3704 of 4622 strings)

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

powered by weblate
2022-02-17 14:50:47 +01:00
Matthias Brück
5ba7ee3516 Translations: Update German
Currently translated at 99.9% (4618 of 4622 strings)

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

powered by weblate
2022-02-17 14:50:47 +01:00
cpoisnel
3706eff795 Fix subevent not shown correctly in order change view (#2473) 2022-02-17 13:59:01 +01:00
Raphael Michel
28331e7538 Fix docs typo 2022-02-16 17:33:48 +01:00
Raphael Michel
62218ca0c6 Fix attendee mails not being sent if no order address set 2022-02-16 17:19:03 +01:00
Raphael Michel
14e2834a72 API: Allow send_email=none during order creation 2022-02-16 17:19:03 +01:00
Richard Schreiber
032653cec4 Localize customer name_parts in email-context (Z#179923) (#2470) 2022-02-16 13:27:54 +01:00
Raphael Michel
f3b355e9f3 Sendmail: Allow to attach tickets to emails 2022-02-16 10:42:48 +01:00
Raphael Michel
f7d2645e76 Fix isort issue 2022-02-14 17:33:20 +01:00
Raphael Michel
fb89e31c1c Bump djangorestframework to 3.13.* 2022-02-14 16:03:34 +01:00
Jonathan Weth
5e1cff53b4 Fix #2456 -- Allow shredding instantly after event end (#2462) 2022-02-14 15:57:38 +01:00
Raphael Michel
61cef87c9d Update .po files 2022-02-14 15:41:29 +01:00
Raphael Michel
2fcab70e3b Add very simple CAPTCHA to standalone customer registration form 2022-02-14 15:37:35 +01:00
Raphael Michel
1414db35b7 Perform some very basic validation on names 2022-02-14 14:56:31 +01:00
Raphael Michel
1d32d7a2d2 Fix copy-paste error in setup.py 2022-02-14 10:04:46 +01:00
Raphael Michel
9966912799 Fix test failing after last commit 2022-02-13 20:44:18 +01:00
Raphael Michel
a37ed6f001 Bump versions of pycodestyle and pep8-naming 2022-02-13 20:39:31 +01:00
dependabot[bot]
a307cf8934 Bump @babel/core from 7.16.7 to 7.17.2 in /src/pretix/static/npm_dir (#2458)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-13 20:25:25 +01:00
Richard Schreiber
0e900b74d7 Fix #2434 -- Disallow manually setting SecretKeySettingsField to ***** (#2436) 2022-02-13 20:24:53 +01:00
dependabot[bot]
7193da42c2 Bump @rollup/plugin-node-resolve from 13.1.2 to 13.1.3 in /src/pretix/static/npm_dir (#2444)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-13 20:23:20 +01:00
dependabot[bot]
48eb580ee8 Bump @babel/preset-env from 7.16.7 to 7.16.11 in /src/pretix/static/npm_dir (#2442)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-13 20:23:14 +01:00
dependabot[bot]
50a5622178 Bump rollup from 2.62.0 to 2.66.1 in /src/pretix/static/npm_dir (#2443)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-13 20:23:07 +01:00
kackey621
66027aed59 Translations: Update Japanese
Currently translated at 0.3% (18 of 4613 strings)

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

powered by weblate
2022-02-13 20:22:51 +01:00
Mauro Amico
8d62e3e2af Translations: Update Italian
Currently translated at 16.7% (774 of 4613 strings)

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

powered by weblate
2022-02-13 20:22:51 +01:00
Ismael Menéndez Fernández
a8ce4845e2 Translations: Update Galician
Currently translated at 4.2% (197 of 4613 strings)

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

powered by weblate
2022-02-13 20:22:51 +01:00
Ismael Menéndez Fernández
efdb834a73 Translations: Update Galician
Currently translated at 1.7% (81 of 4613 strings)

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

powered by weblate
2022-02-13 20:22:51 +01:00
Ismael Menéndez Fernández
fd060b792c Translations: Update Spanish
Currently translated at 64.7% (2987 of 4613 strings)

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

powered by weblate
2022-02-13 20:22:51 +01:00
Jozsef Ebenspanger
31df3e2129 Translations: Update Hungarian
Currently translated at 2.0% (93 of 4613 strings)

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

powered by weblate
2022-02-13 20:22:51 +01:00
Richard Schreiber
c71ba79e55 Fix #2449 -- Format variation-price with Intl.NumberFormat (#2451) 2022-02-13 20:15:51 +01:00
Maico Timmerman
6304b34600 Fix default reply-to header in emails (#2457) 2022-02-13 15:09:22 +01:00
Raphael Michel
81cc7540ec Add ticket secret to order list export 2022-02-11 14:39:00 +01:00
Raphael Michel
adced71706 Fix bugs from af3006a5b 2022-02-08 17:53:26 +01:00
Raphael Michel
8c7ed38441 Orders API: Support require_approval on order creation 2022-02-08 17:34:20 +01:00
Raphael Michel
b4d7d9bf76 Docs: Fix typo in digital content parameter table 2022-02-08 11:57:25 +01:00
Raphael Michel
af3006a5bd Fix mass-creation of vouchers on MySQL 2022-02-07 15:31:09 +01:00
Raphael Michel
d313e076a2 Widget: Fix another IE support bug introduced in Django 3.2 2022-02-07 13:13:04 +01:00
Raphael Michel
216bac2807 Fix getitem usage for non-dictionaries 2022-02-04 17:41:05 +01:00
Raphael Michel
8351e51cfe ORderChangeManager.set_addons: Fix check performed on parent item instead of actual item 2022-02-04 17:08:03 +01:00
Raphael Michel
b2d74dc652 Allow to use AsyncFormView outside of events 2022-02-04 17:08:03 +01:00
Raphael Michel
ea1322165b Add fallback value for getitem template filter 2022-02-04 17:08:03 +01:00
Martin Gross
c65883b328 Presale Order Change: Display public name of item instead of internal 2022-02-04 16:41:04 +01:00
Felix Schäfer
dfd37cc5e3 Fix guard in mail service (#2448) 2022-02-04 15:30:44 +01:00
Raphael Michel
4c71995560 Support for file upload in asynctask.js 2022-02-03 11:21:49 +01:00
Raphael Michel
02034cacbf Fix changing orders when only variants can be changed and no addons 2022-02-02 16:59:00 +01:00
Raphael Michel
d098cda8a8 Add new endpoints to pretixPOS device security profile 2022-02-01 18:10:11 +01:00
Raphael Michel
0b8432b2c5 Docs: Add node on MySQL's SQL mode 2022-01-31 15:44:26 +01:00
Raphael Michel
9be6ad4124 Add documentation on secrets import plugin 2022-01-31 10:20:23 +01:00
Raphael Michel
fdc77f6bd8 Quota deletion: Show internal names of products 2022-01-31 09:56:55 +01:00
Raphael Michel
e3d0a18bee Show internal category names in product list 2022-01-31 09:34:13 +01:00
Raphael Michel
81c271ee2a Fix ordering of add-on products in email info block 2022-01-28 17:05:46 +01:00
Raphael Michel
e981f00dc7 Fix typo 2022-01-27 17:52:37 +01:00
Raphael Michel
2daf35c39e Allow to customize description of calendar files (#2415)
Co-authored-by: Martin Gross <gross@rami.io>
2022-01-27 14:58:16 +01:00
Raphael Michel
c9530c56af Fix isort issue 2022-01-27 14:42:19 +01:00
Raphael Michel
f3e31287f4 Bump to 4.7.0.dev0 2022-01-27 13:44:53 +01:00
Raphael Michel
c9aaa343e6 Bump to 4.6.0 2022-01-27 13:44:39 +01:00
Raphael Michel
87a196c4df Translations: Update German
Currently translated at 100.0% (4613 of 4613 strings)

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

powered by weblate
2022-01-27 12:27:58 +01:00
Raphael Michel
a220f1678b Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4613 of 4613 strings)

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

powered by weblate
2022-01-27 12:27:58 +01:00
Raphael Michel
c8fa0852b2 Add DNS to English word list 2022-01-27 12:15:31 +01:00
Raphael Michel
fe3433106c Extend spelling wordlists 2022-01-27 12:11:34 +01:00
Raphael Michel
f8086daf34 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-01-27 11:02:14 +01:00
Raphael Michel
66f75a5614 Revert dnspython to 1.x 2022-01-26 16:15:12 +01:00
Raphael Michel
6f30c347c0 [SECURITY] Make redirect view dependent on referer 2022-01-26 13:41:02 +01:00
Raphael Michel
3596fa9c5a [SECURITY] Fix (non-exploitable) XSS issue 2022-01-26 13:41:02 +01:00
Raphael Michel
e3c7cd7c6d Redesign of email settings (#2426)
Co-authored-by: Felix Rindt <felix@rindt.me>
2022-01-26 12:47:58 +01:00
Raphael Michel
194042dca5 Add-on selection: Fix incorrect pre-selection across multiple base positions 2022-01-26 09:45:44 +01:00
Raphael Michel
3be6e83f33 Add missing license header 2022-01-25 21:08:28 +01:00
Raphael Michel
4262bce2b5 Limit maximum length of passwords to 4096 characters 2022-01-25 17:24:48 +01:00
Raphael Michel
73ab962e16 Respect language headers on error 400/404/500 pages 2022-01-25 16:59:30 +01:00
Raphael Michel
13a86fc6f3 Event ical feed: Do not show events more than 31 days in the past 2022-01-24 15:47:04 +01:00
Raphael Michel
9d6f11718a Work around performance issue in vobject library 2022-01-24 15:46:48 +01:00
Raphael Michel
c9d3428996 Extend check_order_transactions by number of tickets 2022-01-22 22:00:35 +01:00
Felix Schäfer
d4ef16b31a Fix #2320 - Move file upload "required" attrs manipulation from init to rendering (#2399) 2022-01-21 15:49:24 +01:00
Yuriko Matsunami
6a35e7d3cd Translated on translate.pretix.eu (Japanese)
Currently translated at 97.0% (167 of 172 strings)

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

powered by weblate
2022-01-21 15:49:03 +01:00
DJG Bayern
463443d606 Translations: Update Japanese
Currently translated at 0.1% (8 of 4582 strings)

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

powered by weblate
2022-01-21 15:49:03 +01:00
Raphael Michel
6f0da5c2ca TaxRules: Add internal_name and keep_gross_if_rate_changes (#2422)
Co-authored-by: ser8phin <eva.wolkwitz@gmx.de>
2022-01-21 15:39:27 +01:00
ser8phin
c1344422a5 Remove disabled attribute on checkbox (#2423)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-01-19 21:38:14 +01:00
Raphael Michel
c2bd3dde44 GitHub actions: Do not run on 3.10 yet (too many warnings) 2022-01-19 17:12:15 +01:00
Raphael Michel
9e51736232 Fix GitHub actions scripts (No YAML, Python 3.10 is not 3.1) 2022-01-19 17:01:42 +01:00
Raphael Michel
5b27ce1265 Stop testing Python 3.6 on CI 2022-01-19 17:00:24 +01:00
Raphael Michel
0757542f4f Drop Python 3.6 compatibility 2022-01-19 16:49:19 +01:00
Raphael Michel
12be98c888 Update Pillow to 9.* 2022-01-19 16:46:43 +01:00
Raphael Michel
51e6b02aa9 Docs: Remove mention of local cache backend 2022-01-19 15:24:44 +01:00
Raphael Michel
acc4a167b1 Event series calendar: Fix incorrect show_names heuristic 2022-01-19 14:58:30 +01:00
Richard Schreiber
dd9429bbfa Fix: phone being "None" or format not recognized in checkout (#2420) 2022-01-18 12:27:57 +01:00
Richard Schreiber
768bb8c106 Add phone number to customer profile (Z#178346) (#2414) 2022-01-18 11:38:32 +01:00
Raphael Michel
cbdafac999 Web check-in: Fix search 2022-01-17 14:55:16 +01:00
Raphael Michel
96f694cf61 Translations: Update German (informal) (de_Informal)
Currently translated at 99.9% (4580 of 4582 strings)

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

powered by weblate
2022-01-14 16:12:13 +01:00
Raphael Michel
5576829ebf Translations: Update German
Currently translated at 100.0% (4582 of 4582 strings)

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

powered by weblate
2022-01-14 16:12:13 +01:00
Raphael Michel
b0d67e92ac Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-01-14 15:33:22 +01:00
Yuriko Matsunami
63e28723d2 Translated on translate.pretix.eu (Japanese)
Currently translated at 73.2% (126 of 172 strings)

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

powered by weblate
2022-01-14 14:51:58 +01:00
Mikkel Ricky
cc0656f169 Translations: Update Danish
Currently translated at 35.3% (1613 of 4565 strings)

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

powered by weblate
2022-01-14 14:51:58 +01:00
ser8phin
849c8e719a Fix #555 -- Preselect a single required add-on (#2395) 2022-01-14 14:46:04 +01:00
Raphael Michel
a3ec2a4061 Clarify help text of invoice_address_custom_field 2022-01-14 14:42:42 +01:00
Raphael Michel
00a7187a7a Duplicate line break before invoice deadline 2022-01-13 16:45:15 +01:00
Richard Schreiber
701c4f768e Improve add-to-cart checkbox for items with max. 1 per order (Z#178704) (#2413) 2022-01-12 17:10:00 +01:00
Aya Yabuki
cf751d38d2 Translated on translate.pretix.eu (Japanese)
Currently translated at 16.8% (29 of 172 strings)

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

powered by weblate
2022-01-12 16:21:14 +01:00
Aya Yabuki
888402a4bf Translated on translate.pretix.eu (Japanese)
Currently translated at 16.8% (29 of 172 strings)

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

powered by weblate
2022-01-12 16:21:14 +01:00
Aya Yabuki
1134f610fd Translated on translate.pretix.eu (Japanese)
Currently translated at 8.7% (15 of 172 strings)

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

powered by weblate
2022-01-12 16:21:14 +01:00
Raphael Michel
8ae4304c7d Add workaround for https://github.com/getsentry/responses/issues/464 2022-01-12 10:19:02 +01:00
Raphael Michel
357092ec44 API: Add InvoiceLine.subevent (#2411) 2022-01-10 14:11:06 +01:00
Raphael Michel
70a5c76d79 Allow tax rules to trigger approval requirement (#2409) 2022-01-10 14:10:51 +01:00
ser8phin
7a4db8ea23 Add approval requirement option to product variations (#2381) 2022-01-05 18:04:12 +01:00
Raphael Michel
223b160c0c Fix booked add-ons being hidden in order change due to hide_sold_out 2022-01-05 17:58:21 +01:00
Raphael Michel
30c1771d29 Thumbnail: Support for paletted PNG files 2022-01-04 16:26:13 +01:00
Raphael Michel
b3b7b9bbab Optimize rendering of very large calendars (#2406) 2022-01-04 10:48:48 +01:00
dependabot[bot]
be040cd6ea Bump @babel/core from 7.16.0 to 7.16.7 in /src/pretix/static/npm_dir (#2401)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-01-04 10:33:51 +01:00
dependabot[bot]
c6665ec2e6 Bump @rollup/plugin-node-resolve from 13.0.6 to 13.1.2 in /src/pretix/static/npm_dir (#2403)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-01-04 10:30:57 +01:00
dependabot[bot]
fd16ef1e4d Bump rollup from 2.60.2 to 2.62.0 in /src/pretix/static/npm_dir (#2402)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-01-04 10:30:51 +01:00
dependabot[bot]
39557fc452 Bump @babel/preset-env from 7.16.4 to 7.16.7 in /src/pretix/static/npm_dir (#2404)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-01-04 10:30:44 +01:00
cpoisnel
408397a639 Translations: Update French
Currently translated at 48.8% (2229 of 4565 strings)

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

powered by weblate
2022-01-04 10:30:34 +01:00
Raphael Michel
d4a2500204 Check-in list PDF: Escape HTML tags in names 2022-01-03 12:41:37 +01:00
Raphael Michel
e74d9e56cf Waiting list: Explain that you only get one ticket 2022-01-03 10:43:13 +01:00
Raphael Michel
f3767ab4ac Gift card: Log user who triggered reversal of transaction 2022-01-03 10:39:05 +01:00
Raphael Michel
5d13f5f885 Gift cards: Fix incorrect handling of return key 2022-01-03 10:38:54 +01:00
Raphael Michel
451d3fce05 Cookie consent: Fix crash without localStorage again 2021-12-22 10:29:27 +01:00
Raphael Michel
ccb61e0f56 Docs: Fix dead external link 2021-12-21 11:45:34 +01:00
Richard Schreiber
b6273adc57 Calendar-View: add short_month_day_format for week-views (#2392) 2021-12-21 11:19:58 +01:00
Richard Schreiber
0bf7bba6ba Fix: WEEK_FORMAT fallback in calender week-views (#2391)
* switch to context-week_format for fallback-handling

* set week_format fallback to en instead of de

* add french WEEK_FORMAT and WEEK_DAY_FORMAT
2021-12-21 10:10:13 +01:00
Raphael Michel
7090e0bae2 Event settings: Do not specify fields as optional that are actually required 2021-12-20 19:20:48 +01:00
Raphael Michel
c75cb0b8e3 Cookie consent: Fail softly if localStorage is unavailable 2021-12-20 16:11:33 +01:00
Raphael Michel
3dbf22f670 Remove django-compat from settings.py 2021-12-20 12:22:13 +01:00
Raphael Michel
f26cbdc257 Bump arabic-reshaper to 2.1.3 2021-12-20 09:52:38 +01:00
Raphael Michel
6b4adccee5 Bump django-hijack to 3.1.* 2021-12-20 09:51:52 +01:00
Raphael Michel
c2a8286022 Fix celery-specific issue in 9f4b834ab 2021-12-16 19:06:16 +01:00
Martin Gross
4145887a9b Web checkin: Redirect user to login if session expired (#2383)
Co-authored-by: Raphael Michel <michel@rami.io>
2021-12-16 18:35:09 +01:00
Raphael Michel
9f4b834abc Allow to attach files to order confirmation email (#2384)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2021-12-16 18:34:18 +01:00
Richard Schreiber
8fcc314f09 Add fixed scroll position when navigating calendar views (Z#177488) (#2385)
* add fixed scroll position when navigating calendar views

* change from local to sessionStorage

* add check for sessionStorage
2021-12-16 13:36:10 +01:00
Felix Rindt
94a7d02ab1 Fix event settings form considered changed even if unchanged (#1739)
Co-authored-by: Raphael Michel <michel@rami.io>
2021-12-16 11:27:18 +01:00
Raphael Michel
ad2943263c Fix unnecessary override of default settings 2021-12-16 10:31:46 +01:00
253 changed files with 88401 additions and 71184 deletions

View File

@@ -18,17 +18,17 @@ jobs:
name: Tests
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
python-version: ["3.7", "3.8", "3.9"]
database: [sqlite, postgres, mysql]
exclude:
- database: mysql
python-version: 3.7
- database: sqlite
python-version: 3.7
python-version: "3.8"
- database: mysql
python-version: 3.6
python-version: "3.9"
- database: sqlite
python-version: 3.6
python-version: "3.7"
- database: sqlite
python-version: "3.8"
steps:
- uses: actions/checkout@v2
- uses: getong/mariadb-action@v1.1

View File

@@ -220,12 +220,30 @@ Example::
``user``, ``password``
The SMTP user data to use for the connection. Empty by default.
``tls``, ``ssl``
Use STARTTLS or SSL for the SMTP connection. Off by default.
``from``
The email address to set as ``From`` header in outgoing emails by the system.
Default: ``pretix@localhost``
``tls``, ``ssl``
Use STARTTLS or SSL for the SMTP connection. Off by default.
``from_notifications``
The email address to set as ``From`` header in admin notification emails by the system.
Defaults to the value of ``from``.
``from_organizers``
The email address to set as ``From`` header in outgoing emails by the system sent on behalf of organizers.
Defaults to the value of ``from``.
``custom_sender_verification_required``
If this is on (the default), organizers need to verify email addresses they want to use as senders in their event.
``custom_sender_spf_string``
If this is set to a valid SPF string, pretix will show a warning if organizers use a sender address from a domain
that does not include this value.
``custom_smtp_allow_private_networks``
If this is off (the default), custom SMTP servers cannot be private network addresses.
``admins``
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
@@ -282,7 +300,7 @@ You can use an existing memcached server as pretix's caching backend::
``location``
The location of memcached, either a host:port combination or a socket file.
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
If no memcached is configured, pretix will use redis for caching. If neither is configured, pretix will not use any caching.
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
shared memcached instance, not multiple ones, because cache invalidations would not be
@@ -445,8 +463,10 @@ You can configure the maximum file size for uploading various files::
max_size_image = 12
; Max upload size for favicons in MiB, defaults to 1 MiB
max_size_favicon = 2
; Max upload size for email attachments in MiB, defaults to 10 MiB
; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
max_size_email_attachment = 15
; Max upload size for email attachments of automatically sent emails in MiB, defaults to 1 MiB
max_size_email_auto_attachment = 2
; Max upload size for other files in MiB, defaults to 10 MiB
; This includes all file upload type order questions
max_size_other = 100

View File

@@ -36,9 +36,6 @@ Linux and firewalls, we recommend that you start with `ufw`_.
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
installations except for evaluation purposes.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
.. warning:: By default, using `ufw` in conjunction will not have any effect. Please make sure to either bind the exposed
ports of your docker container explicitly to 127.0.0.1 or configure docker to respect any set up firewall
rules.
@@ -61,6 +58,9 @@ directory writable to the user that runs pretix inside the docker container::
Database
--------
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with
the following command::
@@ -91,6 +91,8 @@ When using MySQL, make sure you set the character set of the database to ``utf8m
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
Redis
-----
@@ -106,6 +108,18 @@ Now restart redis-server::
# systemctl restart redis-server
In this setup, systemd will delete ``/var/run/redis`` on every redis restart, which will cause issues with pretix. To
prevent this, you can execute::
# systemctl edit redis-server
And insert the following::
[Service]
# Keep the directory around so that pretix.service in docker does not need to be
# restarted when redis is restarted.
RuntimeDirectoryPreserve=yes
.. warning:: Setting the socket permissions to 777 is a possible security problem. If you have untrusted users on your
system or have high security requirements, please don't do this and let redis listen to a TCP socket
instead. We recommend the socket approach because the TCP socket in combination with docker's networking

View File

@@ -34,9 +34,6 @@ Linux and firewalls, we recommend that you start with `ufw`_.
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
installations except for evaluation purposes.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
Unix user
---------
@@ -50,6 +47,9 @@ In this guide, all code lines prepended with a ``#`` symbol are commands that yo
Database
--------
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
Having the database server installed, we still need a database and a database user. We can create these with any kind
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
best compatibility. You can check this with the following command::
@@ -65,6 +65,8 @@ When using MySQL, make sure you set the character set of the database to ``utf8m
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
Package dependencies
--------------------
@@ -142,7 +144,7 @@ If you're running MySQL, also install the client library::
(venv)$ pip3 install mysqlclient
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
We also need to create a data directory::
@@ -259,14 +261,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
}
location /static/ {
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
access_log off;
expires 365d;
add_header Cache-Control "public";
}
}
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
above with your python version.
We recommend reading about setting `strong encryption settings`_ for your web server.

View File

@@ -58,6 +58,12 @@ lines list of objects The actual invo
created before this field was introduced as well as for
all lines not created by a product (e.g. a shipping or
cancellation fee).
├ subevent integer Event series date ID used to create this line. Note that everything
about the subevent might have changed since the creation
of the invoice. Can be ``null`` for all invoice lines
created before this field was introduced as well as for
all lines not created by a product (e.g. a shipping or
cancellation fee) as well as for all events that are not a series.
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
all invoice lines
@@ -120,6 +126,10 @@ internal_reference string Customer's refe
The attribute ``lines.event_location`` has been added.
.. versionchanged:: 4.6
The attribute ``lines.subevent`` has been added.
Endpoints
---------
@@ -185,6 +195,7 @@ Endpoints
"description": "Budget Ticket",
"item": 1234,
"variation": 245,
"subevent": null,
"fee_type": null,
"fee_internal_type": null,
"event_date_from": "2017-12-27T10:00:00Z",
@@ -274,6 +285,7 @@ Endpoints
"description": "Budget Ticket",
"item": 1234,
"variation": 245,
"subevent": null,
"fee_type": null,
"fee_internal_type": null,
"event_date_from": "2017-12-27T10:00:00Z",

View File

@@ -24,6 +24,9 @@ active boolean If ``false``, t
description multi-lingual string A public description of the variation. May contain
Markdown syntax or can be ``null``.
position integer An integer, used for sorting
require_approval boolean If ``true``, orders with this variation will need to be
approved by the event organizer before they can be
paid.
require_membership boolean If ``true``, booking this variation requires an active membership.
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership.
@@ -76,6 +79,7 @@ Endpoints
"en": "S"
},
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -97,6 +101,7 @@ Endpoints
"en": "L"
},
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -147,6 +152,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -183,6 +189,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -209,6 +216,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -266,6 +274,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],

View File

@@ -839,6 +839,7 @@ Creating orders
* ``comment`` (optional)
* ``custom_followup_at`` (optional)
* ``checkin_attention`` (optional)
* ``require_approval`` (optional)
* ``invoice_address`` (optional)
* ``company``
@@ -898,8 +899,9 @@ Creating orders
* ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_email`` (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``. Used to be ``send_mail`` before pretix 3.14.
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix'
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
Used to be ``send_mail`` before pretix 3.14.
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
to incrementing integers starting with ``1``. Then, you can reference one of these

View File

@@ -16,15 +16,22 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the tax rule
name multi-lingual string The tax rules' name
internal_name string An optional name that is only used in the backend
rate decimal (string) Tax rate in percent
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
the specified product price
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
home_country string Merchant country (required for reverse charge), can be
``null`` or empty string
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
rules keep the gross price constant (default is ``false``)
===================================== ========================== =======================================================
.. versionchanged:: 4.6
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
Endpoints
---------
@@ -56,9 +63,11 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
]
@@ -94,9 +103,11 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
@@ -140,9 +151,11 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
@@ -185,9 +198,11 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "20.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}

View File

@@ -20,20 +20,31 @@ Basically, three pre-defined flows are supported:
* Authentication mechanisms that rely on **redirection**, e.g. to an OAuth provider. These can be implemented by
supplying a ``authentication_url`` method and implementing a custom return view.
Authentication backends are *not* collected through a signal. Instead, they must explicitly be set through the
``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
For security reasons, authentication backends are *not* automatically discovered through a signal. Instead, they must
explicitly be set through the ``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
In each of these methods (``form_authenticate``, ``request_authenticate`` or your custom view) you are supposed to
either get an existing :py:class:`pretix.base.models.User` object from the database or create a new one. There are a
few rules you need to follow:
In each of these methods (``form_authenticate``, ``request_authenticate``, or your custom view) you are supposed to
use ``User.objects.get_or_create_for_backend`` to get a :py:class:`pretix.base.models.User` object from the database
or create a new one.
* You **MUST** only return users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
There are a few rules you need to follow:
* You **MUST** create new users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
* You **MUST** have some kind of identifier for a user that is globally unique and **SHOULD** never change, even if the
user's name or email address changes. This could e.g. be the ID of the user in an external database. The identifier
must not be longer than 190 characters. If you worry your backend might generated longer identifiers, consider
using a hash function to trim them to a constant length.
* You **SHOULD** not allow users created by other authentication backends to log in through your code, and you **MUST**
only create, modify or return users with ``auth_backend`` set to your backend.
* Every user object **MUST** have an email address. Email addresses are globally unique. If the email address is
already registered to a user who signs in through a different backend, you **SHOULD** refuse the login.
``User.objects.get_or_create_for_backend`` will follow these rules for you automatically. It works like this:
.. autoclass:: pretix.base.models.auth.UserManager
:members: get_or_create_for_backend
The backend interface
---------------------
@@ -59,6 +70,7 @@ The backend interface
.. automethod:: authentication_url
Logging users in
----------------
@@ -68,3 +80,45 @@ recommend that you use the following utility method to correctly set session val
authentication (if activated):
.. autofunction:: pretix.control.views.auth.process_login
A custom view that is called after a redirect from an external identity provider could look like this::
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse
from pretix.base.models import User
from pretix.base.models.auth import EmailAddressTakenError
from pretix.control.views.auth import process_login
def return_view(request):
# Verify validity of login with the external provider's API
api_response = my_verify_login_function(
code=request.GET.get('code')
)
try:
u = User.objects.get_or_create_for_backend(
'my_backend_name',
api_response['userid'],
api_response['email'],
set_always={
'fullname': '{} {}'.format(
api_response.get('given_name', ''),
api_response.get('family_name', ''),
),
},
set_on_creation={
'locale': api_response.get('locale').lower()[:2],
'timezone': api_response.get('zoneinfo', 'UTC'),
}
)
except EmailAddressTakenError:
messages.error(
request, _('We cannot create your user account as a user account in this system '
'already exists with the same email address.')
)
return redirect(reverse('control:auth.login'))
else:
return process_login(request, u, keep_logged_in=False)

View File

@@ -92,6 +92,7 @@ those will be displayed but not block the plugin execution.
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
is available for a specific event. If not, it will not be shown in the plugin list of that event.
You should not define ``is_available`` and ``restricted`` on the same plugin.
Plugin registration
-------------------

View File

@@ -61,7 +61,7 @@ Variable Description
``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``
``answers[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)

View File

@@ -0,0 +1,301 @@
Secrets Import
==============
Usually, pretix generates ticket secrets (i.e. the QR code used for scanning) itself. You can read more about this
process at :ref:`secret_generators`.
With the "Secrets Import" plugin, you can upload your own list of secrets to be used instead. This is useful for
integrating with third-party check-in systems.
API Resource description
-------------------------
The secrets import plugin provides a HTTP API that allows you to create new secrets.
The imported secret resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the secret
secret string Actual string content of the secret (QR code content)
used boolean Whether the secret was already used for a ticket. If ``true``,
the secret can no longer be deleted. Secrets are never used
twice, even if an order is canceled or deleted.
item integer Internal ID of a product, or ``null``. If set, the secret
will only be used for tickets of this product.
variation integer Internal ID of a product variation, or ``null``. If set, the secret
will only be used for tickets of this product variation.
subevent integer Internal ID of an event series date, or ``null``. If set, the secret
will only be used for tickets of this event series date.
===================================== ========================== =======================================================
API Endpoints
-------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/
Returns a list of all secrets imported for an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
Returns information on one secret, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the secret to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/
Create a new secret.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
{
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
:param organizer: The ``slug`` field of the organizer to a create new secret for
:param event: The ``slug`` field of the event to create a new secret for
:statuscode 201: no error
:statuscode 400: The secret could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create secrets.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/bulk_create/
Create new secrets in bulk (up to 500 per request). The request either succeeds or fails entirely.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/bulk_create/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
[
{
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
},
{
"secret": "baz",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
]
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
[
{
"id": 1,
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
},
{
"id": 2,
"secret": "baz",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
]
:param organizer: The ``slug`` field of the organizer to create new secrets for
:param event: The ``slug`` field of the event to create new secrets for
:statuscode 201: no error
:statuscode 400: The secrets could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create secrets.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
Update a secret. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 34
{
"item": 2
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 1,
"secret": "foobar",
"used": false,
"item": 2,
"variation": null,
"subevent": null
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the secret to modify
:statuscode 200: no error
:statuscode 400: The secret could not be modified due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to change it.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
Delete a secret. You can only delete secrets that have not yet been used.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the secret to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to change it **or** the secret has already been used

View File

@@ -17,4 +17,6 @@ If you want to **create** a plugin, please go to the
campaigns
certificates
digital
imported_secrets
webinar
presale-saml

View File

@@ -0,0 +1,405 @@
.. highlight:: ini
.. spelling::
IdP
skIDentity
ePA
NPA
Presale SAML Authentication
===========================
The Presale SAML Authentication plugin is an advanced plugin, which most event
organizers will not need to use. However, for the select few who do require
strong customer authentication that cannot be covered by the built-in customer
account functionality, this plugin allows pretix to connect to a SAML IdP and
perform authentication and retrieval of user information.
Usage of the plugin is governed by two separate sets of settings: The plugin
installation, the Service Provider (SP) configuration and the event
configuration.
Plugin installation and initial configuration
---------------------------------------------
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, you can
skip this section.
The plugin is installed as any other plugin in the pretix ecosystem. As a
pretix system administrator, please follow the instructions in the the
:ref:`Administrator documentation <admindocs>`.
Once installed, you will need to assess, if you want (or need) your pretix
instance to be a single SP for all organizers and events or if every event
organizer has to provide their own SP.
Take the example of a university which runs pretix under an pretix Enterprise
agreement. Since they only provide ticketing services to themselves (every
organizer is still just a different department of the same university), a
single SP should be enough.
On the other hand, a reseller such as `pretix.eu`_ who services a multitude
of clients would not work that way. Here, every organizer is a separate
legal entity and as such will also need to provide their own SP configuration:
Company A will expect their SP to reflect their company - and not a generalized
"pretix SP".
Once you have decided on the mode of operation, the :ref:`Configuration file
<config>` needs to be extended to reflect your choice.
Example::
[presale-saml]
level=global
``level``
``global`` to use only a single, system-wide SP, ``organizer`` for multiple
SPs, configured on the organizer-level. Defaults to ``organizer``.
Service Provider configuration
------------------------------
Global Level
^^^^^^^^^^^^
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, you can
skip this section and follow the instructions on the upcoming
Organizer Level settings.
As a user with administrative privileges, please activate them by clicking the
`Admin Mode` button in the top right hand corner.
You should now see a new menu-item titled `SAML` appear.
Organizer Level
^^^^^^^^^^^^^^^
Navigate to the organizer settings in the pretix backend. In the navigation
bar, you will find a menu-item titled `SAML` if your user has the `Can
change organizer settings` permission.
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, the menu
will only appear once one of our friendly customer service agents
has enabled the Presale SAML Authentication plugin for at least one
of your events. Feel free to get in touch with us!
Setting up the SP
^^^^^^^^^^^^^^^^^
No matter where your SP configuration lives, you will be greeted by a very
long list of fields of which almost all of them will need to be filled. Please
don't be discouraged - most of the settings don't need to be decided by yourself
and/or are already preset with a sensible default setting.
If you are not sure what setting you should choose for any of the fields, you
should reach out to your IdP operator as they can tell you exactly what the IdP
expects and - more importantly - supports.
``IdP Metadata URL``
Please provide the URL where your IdP outputs its metadata. For most IdPs,
this URL is static and the same for all SPs. If you are a member of the
DFN-AAI, you can find the meta-data for the `Test-, Basic- and
Advanced-Federation`_ on their website. Please do talk with your local
IdP operator though, as you might not even need to go through the DFN-AAI
and might just use your institutions local IdP which will also host their
metadata on a different URL.
The URL needs to be publicly accessible, as saving the settings form will
fail if the IdP metadata cannot be retrieved. pretix will also automatically
refresh the IdP metadata on a regular basis.
``SP Entity Id``
By default, we recommend that you use the system-proposed metadata-URL as
the Entity Id of your SP. However, if so desired or required by your IdP,
you can also set any other, arbitrary URL as the SP Entity Id.
``SP Name / SP Decription``
Most IdP will display the name and description of your SP to the users
during authentication. The description field can be used to explain to the
users how their data is being used.
``SP X.509 Certificate / SP X.509 Private Key``
Your SP needs a certificate and a private key for said certificate. Please
coordinate with your IdP, if you are supposed to generate these yourself or
if they are provided to you.
``SP X.509 New Certificate``
As certificates have an expiry date, they need to be renewed on a regular
basis. In order to facilitate the rollover from the expiring to the new
certificate, you can provide the new certificate already before the expiration
of the existing one. That way, the system will automatically use the correct
one. Once the old certificate has expired and is not used anymore at all,
you can move the new certificate into the slot of the normal certificate and
keep the new slot empty for your next renewal process.
``Requested Attributes``
An IdP can hold a variety of attributes of an authenticating user. While
your IdP will dictate which of the available attributes your SP can consume
in theory, you will still need to define exactly which attributes the SP
should request.
The notation is a JSON list of objects with 5 attributes each:
* ``attributeValue``: Can be defaulted to ``[]``.
* ``friendlyName``: String used in the upcoming event-level settings to
retrieve the attributes data.
* ``isRequired``: Boolean indicating whether the IdP must enforce the
transmission of this attribute. In most cases, ``true`` is the best
choice.
* ``name``: String of the internal, technical name of the requested
attribute. Often starting with ``urn:mace:dir:attribute-def:``,
``urn:oid:`` or ``http://``/``https://``.
* ``nameFormat``: String describing the type of ``name`` that has been
set in the previous section. Often starting with
``urn:mace:shibboleth:1.0:`` or ``urn:oasis:names:tc:SAML:2.0:``.
Your IdP can provide you with a list of available attributes. See below
for a sample configuration in an academic context.
Note, that you can have multiple attributes with the same ``friendlyName``
but different ``name``s. This is often used in systems, where the same
information (for example a persons name) is saved in different fields -
for example because one institution is returning SAML 1.0 and other
institutions are returning SAML 2.0 style attributes. Typically, this only
occurs in mix environments like the DFN-AAI with a large number of
participants. If you are only using your own institutions IdP and not
authenticating anyone outside of your realm, this should not be a common
sight.
``Encrypt/Sign/Require ...``
Does what is says on the box - please inquire with your IdP for the
necessary settings. Most settings can be turned on as they increase security,
however some IdPs might stumble over some of them.
``Signature / Digest Algorithm``
Please chose appropriate algorithms, that both pretix/your SP and the IdP
can communicate with. A common source of issues when connecting to a
Shibboleth-based IdP is the Digest Algorithm: pretix does not support
``http://www.w3.org/2009/xmlenc11#rsa-oaep`` and authentication will fail
if the IdP enforces this.
``Technical/Support Contacts``
Those contacts are encoded into the SPs public meta data and might be
displayed to users having trouble authenticating. It is recommended to
provide a dedicated point of contact for technical issues, as those will
be the ones to change the configuration for the SP.
Event / Authentication configuration
------------------------------------
Basic settings
^^^^^^^^^^^^^^
Once the plugin has been enabled for a pretix event using the Plugins-menu from
the event's settings, a new *SAML* menu item will show up.
On this page, the actual authentication can be configured.
``Checkout Explanation``
Since most users probably won't be familiar with why they have to authenticate
to buy a ticket, you can provide them a small blurb here. Markdown is supported.
``Attribute RegEx``
By default, any successful authentication with the IdP will allow the user to
proceed with their purchase. Should the allowed audience needed to be restricted
further, a set of regular Expressions can be used to do this.
An Attribute RegEx of ``{}`` will allow any authenticated user to pass.
A RegEx of ``{ "affiliation": "^(employee@pretix.eu|staff@pretix.eu)$" }`` will
only allow user to pass which have the ``affiliation`` attribute and whose
attribute either matches ``employee@pretix.eu`` or ``staff@pretix.eu``.
Please make sure that the attribute you are querying is also requested from the
IdP in the first place - for a quick check you can have a look at the top of
the page where all currently configured attributes are listed.
``RegEx Fail Explanation``
Only used in conjunction with the above Attribute RegEx. Should the user not
pass the restrictions imposed by the regular expression, the user is shown
this error-message.
If you are - for example in an university context - restricting access to
students only, you might want to explain here that Employees are not allowed
to book tickets.
``Ticket Secret SAML Attribute``
In very specific instances, it might be desirable that the ticket-secret is
not the randomly one generated by pretix but rather based on one of the
users attributes - for example their unique ID or access card number.
To achieve this, the name of a SAML-attribute can be specified here.
It is however necessary to note, that even with this setting in use,
ticket-secrets need to be unique. This is why when this setting is enabled,
the default, pretix-generated ticket-secret is prefixed with the attributes
value.
Example: A users ``cardid`` attribute has the value of ``01189998819991197253``.
The default random ticket secret would have been
``yczygpw9877akz2xwdhtdyvdqwkv7npj``. The resulting new secret will now be
``01189998819991197253_yczygpw9877akz2xwdhtdyvdqwkv7npj``.
That way, the ticket secret is still unique, but when checking into an event,
the user can easily be searched and found using their identifier.
IdP-provided E-Mail addresses, names
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
By default, pretix will only authenticate the user and not process the received
data any further.
However, there are a few exceptions to this rule.
There are a few `magic` attributes that pretix will use to automatically populate
the corresponding fields within the checkout process **and lock them out from
user editing**.
* ``givenName`` and ``sn``: If both of those attributes are present and pretix
is configured to collect the users name, these attributes' values are used
for the given and family name respectively.
* ``email``: If this attribute is present, the E-Mail-address of the users will
be set to the one transmitted through the attributes.
The latter might pose a problem, if the IdP is transmitting an ``email`` attribute
which does contain a system-level mail address which is only used as an internal
identifier but not as a real mailbox. In this case, please consider setting the
``friendlyName`` of the attribute to a different value than ``email`` or removing
this field from the list of requested attributes altogether.
Saving attributes to questions
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
By setting the ``internal identifier`` of a user-defined question to the same name
as a SAML attribute, pretix will save the value of said attribute into the question.
All the same as in the above section on E-Mail addresses, those fields become
non-editable by the user.
Please be aware that some specialty question types might not be compatible with
the SAML attributes due to specific format requirements. If in doubt (or if the
checkout fails/the information is not properly saved), try setting the question
type to a simple type like "Text (one line)".
Notes and configuration examples
--------------------------------
Requesting SAML 1.0 and 2.0 attributes from an academic IdP
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This requests the ``eduPersonPrincipalName`` (also sometimes called EPPN),
``email``, ``givenName`` and ``sn`` both in SAML 1.0 and SAML 2.0 attributes.
.. sourcecode:: json
[
{
"attributeValue": [],
"friendlyName": "eduPersonPrincipalName",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:eduPersonPrincipalName",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "eduPersonPrincipalName",
"isRequired": true,
"name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "email",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:mail",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "email",
"isRequired": true,
"name": "urn:oid:0.9.2342.19200300.100.1.3",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "givenName",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:givenName",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "givenName",
"isRequired": true,
"name": "urn:oid:2.5.4.42",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "sn",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:sn",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "sn",
"isRequired": true,
"name": "urn:oid:2.5.4.4",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
}
]
skIDentity IdP Metadata URL
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Since the IdP Metadata URL for `skIDentity`_ is not readily documented/visible
in their backend, we document it here:
``https://service.skidentity.de/fs/saml/metadata``
Requesting skIDentity attributes for electronic identity cards
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This requests the basic ``eIdentifier``, ``IDType``, ``IDIssuer``, and
``NameID`` from the `skIDentity`_ SAML service, which are available for
electronic ID cards such as the German ePA/NPA. (Other attributes such as
the name and address are available at additional cost from the IdP).
.. sourcecode:: json
[
{
"attributeValue": [],
"friendlyName": "eIdentifier",
"isRequired": true,
"name": "http://www.skidentity.de/att/eIdentifier",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "IDType",
"isRequired": true,
"name": "http://www.skidentity.de/att/IDType",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "IDIssuer",
"isRequired": true,
"name": "http://www.skidentity.de/att/IDIssuer",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "NameID",
"isRequired": true,
"name": "http://www.skidentity.de/att/NameID",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
}
]
.. _pretix.eu: https://pretix.eu
.. _Test-, Basic- and Advanced-Federation: https://doku.tid.dfn.de/en:metadata
.. _skIDentity: https://www.skidentity.de/

View File

@@ -203,4 +203,4 @@ Then, please contact support@pretix.eu and we will enable DKIM for your domain o
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
.. _SPF specification: http://www.openspf.org/SPF_Record_Syntax
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax

View File

@@ -1,3 +1,5 @@
.. _secret_generators:
Ticket secret generators
========================

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "4.6.0.dev0"
__version__ = "4.7.0"

View File

@@ -167,6 +167,8 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlist-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
('POST', 'plugins:pretix_posbackend:order.poslock'),
('DELETE', 'plugins:pretix_posbackend:order.poslock'),
('DELETE', 'api-v1:cartposition-detail'),
('GET', 'api-v1:giftcard-list'),
('POST', 'api-v1:giftcard-transact'),
@@ -174,6 +176,8 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
('POST', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
('POST', 'plugins:pretix_posbackend:posdebuglogentry-list'),
('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'),
('GET', 'plugins:pretix_posbackend:poscashier-list'),
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
('GET', 'api-v1:revokedsecrets-list'),

View File

@@ -60,7 +60,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
for item in full_data.get('limit_products'):
for item in full_data.get('limit_products', []):
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))

View File

@@ -637,7 +637,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta:
model = TaxRule
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
class EventSettingsSerializer(SettingsSerializer):
@@ -713,7 +713,6 @@ class EventSettingsSerializer(SettingsSerializer):
'ticket_download_require_validated_email',
'ticket_secret_length',
'mail_prefix',
'mail_from',
'mail_from_name',
'mail_attach_ical',
'mail_attach_tickets',

View File

@@ -58,8 +58,9 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
def __init__(self, *args, **kwargs):
@@ -74,8 +75,9 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
def __init__(self, *args, **kwargs):
@@ -249,9 +251,12 @@ class ItemSerializer(I18nAwareModelSerializer):
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', [])
item = Item.objects.create(**validated_data)
if picture:
item.picture.save(os.path.basename(picture.name), picture)
if require_membership_types:
item.require_membership_types.add(*require_membership_types)
for variation_data in variations_data:
require_membership_types = variation_data.pop('require_membership_types', [])

View File

@@ -934,7 +934,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
force = serializers.BooleanField(default=False, required=False)
payment_date = serializers.DateTimeField(required=False, allow_null=True)
send_email = serializers.BooleanField(default=False, required=False)
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
require_approval = serializers.BooleanField(default=False, required=False)
simulate = serializers.BooleanField(default=False, required=False)
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False)
@@ -947,7 +948,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
model = Order
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at')
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'require_approval')
def validate_payment_provider(self, pp):
if pp is None:
@@ -1041,6 +1042,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
force = validated_data.pop('force', False)
simulate = validated_data.pop('simulate', False)
self._send_mail = validated_data.pop('send_email', False)
if self._send_mail is None:
self._send_mail = validated_data.get('sales_channel') in self.context['event'].settings.mail_sales_channel_placed_paid
if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address')
@@ -1219,6 +1222,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.meta_info = "{}"
order.total = Decimal('0.00')
if validated_data.get('require_approval') is not None:
order.require_approval = validated_data['require_approval']
if simulate:
order = WrappedModel(order)
order.last_modified = now()
@@ -1426,7 +1431,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceLine
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
'fee_internal_type', 'event_location')

View File

@@ -132,7 +132,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
def exporters(self):
exporters = []
responses = register_data_exporters.send(self.request.event)
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
return exporters

View File

@@ -646,7 +646,11 @@ class OrderViewSet(viewsets.ModelViewSet):
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
not order.require_approval and payment.provider == "free"
)
if free_flow:
if order.require_approval:
email_template = request.event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_flow:
email_template = request.event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
email_attendees = request.event.settings.mail_send_order_free_attendee
@@ -659,12 +663,13 @@ class OrderViewSet(viewsets.ModelViewSet):
_order_placed_email(
request.event, order, payment.payment_provider if payment else None, email_template,
log_entry, invoice, payment
log_entry, invoice, payment, is_free=free_flow
)
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(request.event, order, p, email_attendees_template, log_entry)
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry,
is_free=free_flow)
if not free_flow and order.status == Order.STATUS_PAID and payment:
payment._send_paid_mail(invoice, None, '')

View File

@@ -94,6 +94,9 @@ class BaseAuthBackend:
This method will be called after the user filled in the login form. ``request`` will contain
the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``.
You are expected to either return a ``User`` object (if login was successful) or ``None``.
You are expected to either return a ``User`` object (if login was successful) or ``None``. You should
obtain this user object using ``User.objects.get_or_create_for_backend``.
"""
return
@@ -104,7 +107,9 @@ class BaseAuthBackend:
reverse proxy, you can directly return a ``User`` object that will be logged in.
``request`` will contain the current request.
You are expected to either return a ``User`` object (if login was successful) or ``None``.
You are expected to either return a ``User`` object (if login was successful) or ``None``. You should
obtain this user object using ``User.objects.get_or_create_for_backend``.
"""
return
@@ -146,7 +151,8 @@ class NativeAuthBackend(BaseAuthBackend):
d = OrderedDict([
('email', forms.EmailField(label=_("E-mail"), max_length=254,
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
max_length=4096)),
])
return d

View File

@@ -33,6 +33,7 @@ from django.core.mail.backends.smtp import EmailBackend
from django.db.models import Count
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import (
get_language, gettext_lazy as _, pgettext_lazy,
@@ -164,9 +165,20 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
has_addons=Count('addons')
))
htmlctx['cart'] = [(k, list(v)) for k, v in groupby(
positions, key=lambda op: (
op.item, op.variation, op.subevent, op.attendee_name,
(op.pk if op.addon_to_id else None), (op.pk if op.has_addons else None)
sorted(
positions,
key=lambda op: (
(op.addon_to.positionid if op.addon_to_id else op.positionid),
op.positionid
)
),
key=lambda op: (
op.item,
op.variation,
op.subevent,
op.attendee_name,
(op.pk if op.addon_to_id else None),
(op.pk if op.has_addons else None)
)
)]
@@ -453,6 +465,15 @@ def base_placeholders(sender, **kwargs):
}
),
),
SimpleFunctionalMailTextPlaceholder(
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
lambda event: str(event.location or ''),
),
SimpleFunctionalMailTextPlaceholder(
'event_admission_time', ['event_or_subevent'],
lambda event_or_subevent: date_format(event_or_subevent.date_admission, 'TIME_FORMAT') if event_or_subevent.date_admission else '',
lambda event: date_format(event.date_admission, 'TIME_FORMAT') if event.date_admission else '',
),
SimpleFunctionalMailTextPlaceholder(
'subevent', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
@@ -622,6 +643,10 @@ def base_placeholders(sender, **kwargs):
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
v
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
v
))
return ph

View File

@@ -573,6 +573,7 @@ class OrderListExporter(MultiSheetListExporter):
pgettext('address', 'State'),
_('Voucher'),
_('Pseudonymization ID'),
_('Ticket secret'),
_('Seat ID'),
_('Seat name'),
_('Seat zone'),
@@ -669,6 +670,7 @@ class OrderListExporter(MultiSheetListExporter):
op.state or '',
op.voucher.code if op.voucher else '',
op.pseudonymization_id,
op.secret,
]
if op.seat:

View File

@@ -38,6 +38,7 @@ import i18nfield.forms
from django import forms
from django.forms.models import ModelFormMetaclass
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from formtools.wizard.views import SessionWizardView
from hierarkey.forms import HierarkeyForm
@@ -118,6 +119,33 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
self.cleaned_data[k] = self.initial[k]
return super().save()
def clean(self):
d = super().clean()
# There is logic in HierarkeyForm.save() to only persist fields that changed. HierarkeyForm determines if
# something changed by comparing `self._s.get(name)` to `value`. This leaves an edge case open for multi-lingual
# text fields. On the very first load, the initial value in `self._s.get(name)` will be a LazyGettextProxy-based
# string. However, only some of the languages are usually visible, so even if the user does not change anything
# at all, it will be considered a changed value and stored. We do not want that, as it makes it very hard to add
# languages to an organizer/event later on. So we trick it and make sure nothing gets changed in that situation.
for name, field in self.fields.items():
if isinstance(field, SecretKeySettingsField) and d.get(name) == SECRET_REDACTED and not self.initial.get(name):
self.add_error(
name,
_('Due to technical reasons you cannot set inputs, that need to be masked (e.g. passwords), to %(value)s.') % {'value': SECRET_REDACTED}
)
if isinstance(field, i18nfield.forms.I18nFormField):
value = d.get(name)
if not value:
continue
current = self._s.get(name, as_type=type(value))
if name not in self.changed_data:
d[name] = current
return d
def get_new_filename(self, name: str) -> str:
from pretix.base.models import Event

View File

@@ -154,6 +154,7 @@ class RegistrationForm(forms.Form):
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
@@ -161,6 +162,7 @@ class RegistrationForm(forms.Form):
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
}),
max_length=4096,
required=True
)
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
@@ -204,11 +206,13 @@ class PasswordRecoverForm(forms.Form):
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput,
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput
widget=forms.PasswordInput,
max_length=4096,
)
def __init__(self, user_id=None, *args, **kwargs):

View File

@@ -47,7 +47,9 @@ from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.validators import (
MaxValueValidator, MinValueValidator, RegexValidator,
)
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.utils import translation
@@ -187,6 +189,15 @@ class NamePartsFormField(forms.MultiValueField):
defaults = {
'widget': self.widget,
'max_length': kwargs.pop('max_length', None),
'validators': [
RegexValidator(
# The following characters should never appear in a name anywhere of
# the world. However, they commonly appear in inputs generated by spam
# bots.
r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.')
)
]
}
self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
@@ -207,6 +218,7 @@ class NamePartsFormField(forms.MultiValueField):
if fname == 'title' and self.scheme_titles:
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
@@ -215,6 +227,7 @@ class NamePartsFormField(forms.MultiValueField):
elif fname == 'salutation':
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[('', '---')] + PERSON_NAME_SALUTATIONS
@@ -333,23 +346,41 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def guess_country(event):
# Try to guess the initial country from either the country of the merchant
# or the locale. This will hopefully save at least some users some scrolling :)
locale = get_language_without_region()
country = event.settings.region or event.settings.invoice_address_from_country
if not country:
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
# TODO: does this actually work?
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale.upper() in valid_countries:
country = Country(locale.upper())
country = get_country_by_locale(get_language_without_region())
return country
def get_country_by_locale(locale):
country = None
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
# TODO: does this actually work?
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale.upper() in valid_countries:
country = Country(locale.upper())
return country
def guess_phone_prefix(event):
with language(get_babel_locale()):
country = str(guess_country(event))
return get_phone_prefix(country)
def get_phone_prefix(country):
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if country in values:
return prefix
return None
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
@@ -780,25 +811,26 @@ class BaseQuestionsForm(forms.Form):
if q.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
elif q.type == Question.TYPE_PHONENUMBER:
with language(get_babel_locale()):
default_country = guess_country(event)
default_prefix = None
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if str(default_country) in values:
default_prefix = prefix
if initial:
try:
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
initial = PhoneNumber().from_string(initial.answer)
except NumberParseException:
initial = None
field = PhoneNumberField(
label=label, required=required,
help_text=help_text,
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
if not initial:
phone_prefix = guess_phone_prefix(event)
if phone_prefix:
initial = "+{}.".format(phone_prefix)
field = PhoneNumberField(
label=label, required=required,
help_text=help_text,
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field.question = q
if answers:
# Cache the answer object for later use

View File

@@ -86,14 +86,6 @@ class TimePickerWidget(forms.TimeInput):
class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs):
# Browsers can't recognize that the server already has a file uploaded
# Don't mark this input as being required if we already have an answer
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
attrs = kwargs.get('attrs', {})
if kwargs.get('required') and kwargs.get('initial'):
attrs.update({'required': None})
kwargs.update({'attrs': attrs})
self.position = kwargs.pop('position')
self.event = kwargs.pop('event')
self.answer = kwargs.pop('answer')
@@ -125,6 +117,15 @@ class UploadedFileWidget(forms.ClearableFileInput):
'answer': self.answer.pk,
})
def get_context(self, name, value, attrs):
# Browsers can't recognize that the server already has a file uploaded
# Don't mark this input as being required if we already have an answer
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
ctx = super().get_context(name, value, attrs)
if ctx['widget']['is_initial']:
ctx['widget']['attrs']['required'] = False
return ctx
def format_value(self, value):
if self.is_initial(value):
return self.FakeFile(value, self.position, self.event, self.answer)

View File

@@ -23,7 +23,9 @@ from decimal import Decimal
from django.core.management.base import BaseCommand
from django.db import models
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
from django.db.models import (
Case, Count, F, OuterRef, Q, Subquery, Sum, Value, When,
)
from django.db.models.functions import Coalesce
from django_scopes import scopes_disabled
@@ -45,6 +47,18 @@ class Command(BaseCommand):
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
position_cnt=Case(
When(Q(status__in=('e', 'c')) | Q(require_approval=True), then=Value(0)),
default=Coalesce(
Subquery(
OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Count('*')).values('p'),
output_field=models.IntegerField()
), Value(0), output_field=models.IntegerField()
),
output_field=models.IntegerField()
),
fee_total=Coalesce(
Subquery(
OrderFee.objects.filter(
@@ -61,6 +75,15 @@ class Command(BaseCommand):
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
tx_cnt=Coalesce(
Subquery(
Transaction.objects.filter(
order=OuterRef('pk'),
item__isnull=False,
).order_by().values('order').annotate(p=Sum(F('count'))).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
).annotate(
correct_total=Case(
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
@@ -70,13 +93,15 @@ class Command(BaseCommand):
),
).exclude(
total=F('position_total') + F('fee_total'),
tx_total=F('correct_total')
tx_total=F('correct_total'),
tx_cnt=F('position_cnt')
).select_related('event')
for o in qs:
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001'):
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001') \
and o.tx_cnt == o.position_cnt:
# Ignore SQLite which treats Decimals like floats…
continue
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}")
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}")
self.stderr.write(self.style.SUCCESS(f'Check completed.'))
self.stderr.write(self.style.SUCCESS('Check completed.'))

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.9 on 2021-12-13 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0204_orderposition_backfill_is_bundled'),
]
operations = [
migrations.AddField(
model_name='itemvariation',
name='require_approval',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.9 on 2022-01-12 10:59
import phonenumber_field.modelfields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0205_itemvariation_require_approval'),
]
operations = [
migrations.AddField(
model_name='customer',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.2.4 on 2022-01-19 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0206_customer_phone'),
]
operations = [
migrations.AddField(
model_name='taxrule',
name='internal_name',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='taxrule',
name='keep_gross_if_rate_changes',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.2.4 on 2022-02-14 16:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0207_auto_20220119_1427'),
]
operations = [
migrations.AddField(
model_name='user',
name='auth_backend_identifier',
field=models.CharField(db_index=True, max_length=190, null=True),
),
migrations.AlterUniqueTogether(
name='user',
unique_together={('auth_backend', 'auth_backend_identifier')},
),
]

View File

@@ -44,7 +44,7 @@ from django.contrib.auth.models import (
)
from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db import IntegrityError, models, transaction
from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.timezone import now
@@ -61,6 +61,10 @@ from pretix.helpers.urls import build_absolute_uri
from .base import LoggingMixin
class EmailAddressTakenError(IntegrityError):
pass
class UserManager(BaseUserManager):
"""
This is the user manager for our custom user model. See the User
@@ -83,6 +87,116 @@ class UserManager(BaseUserManager):
user.save()
return user
def get_or_create_for_backend(self, backend, identifier, email, set_always, set_on_creation):
"""
This method should be used by third-party authentication backends to log in a user.
It either returns an already existing user or creates a new user.
In pretix 4.7 and earlier, email addresses were the only property to identify a user with.
Starting with pretix 4.8, backends SHOULD instead use a unique, immutable identifier
based on their backend data store to allow for changing email addresses.
This method transparently handles the conversion of old user accounts and adds the
backend identifier to their database record.
This method will never return users managed by a different authentication backend.
If you try to create an account with an email address already blocked by a different
authentication backend, :py:class:`EmailAddressTakenError` will be raised. In this case,
you should display a message to the user.
:param backend: The `identifier` attribute of the authentication backend
:param identifier: The unique, immutable identifier of this user, max. 190 characters
:param email: The user's email address
:param set_always: A dictionary of fields to update on the user model on every login
:param set_on_creation: A dictionary of fields to set on the user model if it's newly created
:return: A `User` instance.
"""
if identifier is None:
raise ValueError('You need to supply a custom, unique identifier for this user.')
if email is None:
raise ValueError('You need to supply an email address for this user.')
if 'auth_backend_identifier' in set_always or 'auth_backend_identifier' in set_on_creation or \
'auth_backend' in set_always or 'auth_backend' in set_on_creation:
raise ValueError('You may not update auth_backend/auth_backend_identifier.')
if len(identifier) > 190:
raise ValueError('The user identifier must not be more than 190 characters.')
# Always update the email address
set_always.update({'email': email})
# First, check if we find the user based on it's backend-specific authenticator
try:
u = self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
dirty = False
for k, v in set_always.items():
if getattr(u, k) != v:
setattr(u, k, v)
dirty = True
if dirty:
try:
with transaction.atomic():
u.save(update_fields=set_always.keys())
except IntegrityError:
# This might only raise IntegrityError if the email address is used
# by someone else
raise EmailAddressTakenError()
return u
except self.model.DoesNotExist:
pass
# Second, check if we find the user based on their email address and this backend
try:
u = self.get(
auth_backend=backend,
auth_backend_identifier__isnull=True,
email=email,
)
u.auth_backend_identifier = identifier
for k, v in set_always.items():
setattr(u, k, v)
try:
with transaction.atomic():
u.save(update_fields=['auth_backend_identifier'] + list(set_always.keys()))
return u
except IntegrityError:
# This might only raise IntegrityError if this code is being executed twice
# and runs into a race condition, this mechanism is taken from Django's
# get_or_create
try:
return self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
except self.model.DoesNotExist:
pass
raise
except self.model.DoesNotExist:
pass
# Third, create a new user
u = User(
auth_backend=backend,
auth_backend_identifier=identifier,
**set_on_creation,
**set_always,
)
try:
u.save(force_insert=True)
return u
except IntegrityError:
# This might either be a race condition or the email address is taken
# by a different backend
try:
return self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
except self.model.DoesNotExist:
raise EmailAddressTakenError()
def generate_notifications_token():
return get_random_string(length=32)
@@ -117,6 +231,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:type needs_password_change: bool
:param timezone: The user's preferred timezone.
:type timezone: str
:param auth_backend: The identifier of the authentication backend plugin responsible for managing this user.
:type auth_backend: str
:param auth_backend_identifier: The native identifier of the user provided by a non-native authentication backend.
:type auth_backend_identifier: str
"""
USERNAME_FIELD = 'email'
@@ -152,6 +270,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
)
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
auth_backend = models.CharField(max_length=255, default='native')
auth_backend_identifier = models.CharField(max_length=190, db_index=True, null=True, blank=True)
session_token = models.CharField(max_length=32, default=generate_session_token)
objects = UserManager()
@@ -164,6 +283,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
verbose_name = _("User")
verbose_name_plural = _("Users")
ordering = ('email',)
unique_together = (('auth_backend', 'auth_backend_identifier'),)
def save(self, *args, **kwargs):
self.email = self.email.lower()

View File

@@ -221,7 +221,7 @@ class CheckinList(LoggedModel):
return rules
if operator in ('or', 'and') and seen_nonbool:
raise ValidationError(f'You cannot use OR/AND logic on a level below a comparison operator.')
raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.')
for v in values:
cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1)

View File

@@ -29,6 +29,7 @@ from django.db.models import F, Q
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
@@ -45,6 +46,7 @@ class Customer(LoggedModel):
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
identifier = models.CharField(max_length=190, db_index=True, unique=True)
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
password = models.CharField(verbose_name=_('Password'), max_length=128)
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_parts = models.JSONField(default=dict)
@@ -87,6 +89,7 @@ class Customer(LoggedModel):
self.name_parts = {}
self.name_cached = ''
self.email = None
self.phone = None
self.save()
self.all_logentries().update(data={}, shredded=True)
self.orders.all().update(customer=None)
@@ -169,6 +172,7 @@ class Customer(LoggedModel):
return salted_hmac(key_salt, payload).hexdigest()
def get_email_context(self):
from pretix.base.email import get_name_parts_localized
ctx = {
'name': self.name,
'organizer': self.organizer.name,
@@ -177,7 +181,13 @@ class Customer(LoggedModel):
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
ctx['name_%s' % f] = self.name_parts.get(f, '')
ctx['name_%s' % f] = get_name_parts_localized(self.name_parts, f)
if "concatenation_for_salutation" in name_scheme:
ctx['name_for_salutation'] = name_scheme["concatenation_for_salutation"](self.name_parts)
else:
ctx['name_for_salutation'] = name_scheme["concatenation"](self.name_parts)
return ctx
@property

View File

@@ -665,13 +665,13 @@ class Event(EventMixin, LoggedModel):
return locking.LockManager(self)
def get_mail_backend(self, timeout=None, force_custom=False):
def get_mail_backend(self, timeout=None):
"""
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the event's settings.
"""
if self.settings.smtp_use_custom or force_custom:
if self.settings.smtp_use_custom:
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host=self.settings.smtp_host,
port=self.settings.smtp_port,
@@ -1179,21 +1179,21 @@ class Event(EventMixin, LoggedModel):
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
def set_active_plugins(self, modules, allow_restricted=False):
def set_active_plugins(self, modules, allow_restricted=frozenset()):
plugins_active = self.get_plugins()
plugins_available = self.get_available_plugins()
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and not allow_restricted:
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=False):
def enable_plugin(self, module, allow_restricted=frozenset()):
plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css

View File

@@ -44,7 +44,7 @@ import dateutil.parser
import pytz
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.core.validators import MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Q
from django.utils import formats
@@ -479,12 +479,14 @@ class Item(LoggedModel):
min_per_order = models.IntegerField(
verbose_name=_('Minimum amount per order'),
null=True, blank=True,
validators=[MinValueValidator(0)],
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
'the field empty or set it to 0, there is no special limit for this product.')
)
max_per_order = models.IntegerField(
verbose_name=_('Maximum amount per order'),
null=True, blank=True,
validators=[MinValueValidator(0)],
help_text=_('This product can only be bought at most this many times within one order. If you keep the field '
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
'number of items in the whole order applies regardless.')
@@ -764,6 +766,9 @@ class ItemVariation(models.Model):
:type default_price: decimal.Decimal
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
approval by an administrator
:type require_approval: bool
"""
item = models.ForeignKey(
Item,
@@ -799,6 +804,13 @@ class ItemVariation(models.Model):
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
require_approval = models.BooleanField(
verbose_name=_('Require approval'),
default=False,
help_text=_('If this variation is part of an order, the order will be put into an "approval" state and '
'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for '
'discounted tickets that are only available to specific groups.'),
)
require_membership = models.BooleanField(
verbose_name=_('Require a valid membership'),
default=False,
@@ -832,7 +844,7 @@ class ItemVariation(models.Model):
blank=True,
)
hide_without_voucher = models.BooleanField(
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
verbose_name=_('Show only if a matching voucher is redeemed.'),
default=False,
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
'that unlocks this variation.')
@@ -1687,7 +1699,7 @@ class Quota(LoggedModel):
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
if item.has_variations:
if not any(var.item == item for var in variations):
if not variations or not any(var.item == item for var in variations):
raise ValidationError(_('One or more items has variations but none of these are in the variations list.'))
@staticmethod

View File

@@ -950,7 +950,7 @@ class Order(LockModel, LoggedModel):
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
attach_ical=False):
attach_ical=False, attach_other_files: list=None):
"""
Sends an email to the user that placed this order. Basically, this method does two things:
@@ -976,7 +976,7 @@ class Order(LockModel, LoggedModel):
SendMailException, TolerantDict, mail, render_mail,
)
if not self.email:
if not self.email and not (position and position.attendee_email):
return
for k, v in self.event.meta_data.items():
@@ -994,7 +994,8 @@ class Order(LockModel, LoggedModel):
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical
position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
except SendMailException:
raise
@@ -1441,6 +1442,15 @@ class AbstractPosition(models.Model):
lines = [r.strip() for r in lines if r]
return '\n'.join(lines).strip()
def requires_approval(self, invoice_address=None):
if self.item.require_approval:
return True
if self.variation and self.variation.require_approval:
return True
if self.item.tax_rule and self.item.tax_rule._require_approval(invoice_address):
return True
return False
class OrderPayment(models.Model):
"""
@@ -1714,10 +1724,10 @@ class OrderPayment(models.Model):
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
try:
self.order.send_mail(
position.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[], position=position,
invoices=[],
attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical
)
@@ -2316,7 +2326,7 @@ class OrderPosition(AbstractPosition):
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, attach_ical=False):
auth=None, attach_tickets=False, attach_ical=False, attach_other_files: list=None):
"""
Sends an email to the attendee. Basically, this method does two things:
@@ -2357,6 +2367,7 @@ class OrderPosition(AbstractPosition):
invoices=invoices,
attach_tickets=attach_tickets,
attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
except SendMailException:
raise

View File

@@ -191,12 +191,12 @@ class Organizer(LoggedModel):
e.delete()
self.teams.all().delete()
def get_mail_backend(self, timeout=None, force_custom=False):
def get_mail_backend(self, timeout=None):
"""
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the organizer's settings.
"""
if self.settings.smtp_use_custom or force_custom:
if self.settings.smtp_use_custom:
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host=self.settings.smtp_host,
port=self.settings.smtp_port,

View File

@@ -26,7 +26,7 @@ import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models import Exists, F, OuterRef, Q, Subquery, Value
from django.db.models.functions import Power
from django.utils.deconstruct import deconstructible
from django.utils.timezone import now
@@ -281,10 +281,26 @@ class Seat(models.Model):
q = Q(has_order=True) | Q(has_voucher=True)
if ignore_cart is not True:
q |= Q(has_cart=True)
# The following looks like it makes no sense. Why wouldn't we just use ``Value(self.x)``, we already now
# the value? The reason is that x and y are floating point values generated from our JSON files. As it turns
# out, PostgreSQL MIGHT store floating point values with a different precision based on the underlying system
# architecture. So if we generate e.g. 670.247128887222289 from the JSON file and store it to the database,
# PostgreSQL will store it as 670.247128887222289 internally. However if we query it again, we only get
# 670.247128887222 back. But if we do calculations with a field in PostgreSQL itself, it uses the full
# precision for the calculation.
# We don't actually care about the results with this precision, but we care that the results from this
# function are exactly the same as from event.free_seats(), so we do this subquery trick to deal with
# PostgreSQL's internal values in both cases.
# In the long run, we probably just want to round the numbers on insert...
# See also https://www.postgresql.org/docs/11/runtime-config-client.html#GUC-EXTRA-FLOAT-DIGITS
self_x = Subquery(Seat.objects.filter(pk=self.pk).values('x'))
self_y = Subquery(Seat.objects.filter(pk=self.pk).values('y'))
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())
Power(F('x') - self_x, Value(2), output_field=models.FloatField()) +
Power(F('y') - self_y, Value(2), output_field=models.FloatField())
)
).exclude(pk=self.pk).filter(
q,

View File

@@ -81,6 +81,15 @@ class TaxedPrice:
name=self.name,
)
def __eq__(self, other):
return (
self.gross == other.gross and
self.net == other.net and
self.tax == other.tax and
self.rate == other.rate and
self.name == other.name
)
TAXED_ZERO = TaxedPrice(
gross=Decimal('0.00'),
@@ -127,8 +136,13 @@ def cc_to_vat_prefix(country_code):
class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
internal_name = models.CharField(
verbose_name=_('Internal name'),
max_length=190,
null=True, blank=True,
)
name = I18nCharField(
verbose_name=_('Name'),
verbose_name=_('Official name'),
help_text=_('Should be short, e.g. "VAT"'),
max_length=190,
)
@@ -141,6 +155,10 @@ class TaxRule(LoggedModel):
verbose_name=_("The configured product prices include the tax amount"),
default=True,
)
keep_gross_if_rate_changes = models.BooleanField(
verbose_name=_("Keep gross amount constant if the tax rate changes based on the invoice address"),
default=False,
)
eu_reverse_charge = models.BooleanField(
verbose_name=_("Use EU reverse charge taxation rules"),
default=False,
@@ -198,6 +216,8 @@ class TaxRule(LoggedModel):
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
if self.eu_reverse_charge:
s += ' ({})'.format(_('reverse charge enabled'))
if self.internal_name:
return f'{self.internal_name} ({s})'
return str(s)
@property
@@ -211,7 +231,7 @@ class TaxRule(LoggedModel):
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
if rule.get('action', 'vat') in ('vat', 'require_approval') and rule.get('rate') is not None:
return Decimal(rule.get('rate'))
return Decimal(self.rate)
@@ -228,13 +248,19 @@ class TaxRule(LoggedModel):
rate = override_tax_rate
elif invoice_address:
adjust_rate = self.tax_rate_for(invoice_address)
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price) and base_price_is == 'gross':
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price or self.keep_gross_if_rate_changes) and base_price_is == 'gross':
rate = adjust_rate
elif 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')
if self.keep_gross_if_rate_changes:
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.gross
base_price_is = 'gross'
subtract_from_gross = Decimal('0.00')
else:
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'):
@@ -337,12 +363,19 @@ class TaxRule(LoggedModel):
return False
def _require_approval(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'require_approval':
return True
return False
def _tax_applicable(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
return rule.get('action', 'vat') == 'vat'
return rule.get('action', 'vat') in ('vat', 'require_approval')
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!

View File

@@ -273,6 +273,11 @@ class RelativeDateTimeField(forms.MultiValueField):
minutes_before=None
))
def has_changed(self, initial, data):
if initial is None:
initial = self.widget.decompress(initial)
return super().has_changed(initial, data)
def clean(self, value):
if value[0] == 'absolute' and not value[1]:
raise ValidationError(self.error_messages['incomplete'])

View File

@@ -426,10 +426,10 @@ class CartManager:
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
bundled_sum=bundled_sum)
@@ -1106,10 +1106,11 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
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
totaldiff += new_gross - pos.price
pos.price = new_gross
if not pos.item.tax_rule.keep_gross_if_rate_changes:
current_net = pos.price - pos.tax_value
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
totaldiff += new_gross - pos.price
pos.price = new_gross
pos.includes_tax = rate != Decimal('0.00')
pos.override_tax_rate = rate
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])

View File

@@ -373,22 +373,22 @@ class SQLLogic:
).astimezone(pytz.UTC))
elif values[0] == 'date_from':
return Coalesce(
F(f'subevent__date_from'),
F(f'order__event__date_from'),
F('subevent__date_from'),
F('order__event__date_from'),
)
elif values[0] == 'date_to':
return Coalesce(
F(f'subevent__date_to'),
F(f'subevent__date_from'),
F(f'order__event__date_to'),
F(f'order__event__date_from'),
F('subevent__date_to'),
F('subevent__date_from'),
F('order__event__date_to'),
F('order__event__date_from'),
)
elif values[0] == 'date_admission':
return Coalesce(
F(f'subevent__date_admission'),
F(f'subevent__date_from'),
F(f'order__event__date_admission'),
F(f'order__event__date_from'),
F('subevent__date_admission'),
F('subevent__date_from'),
F('order__event__date_admission'),
F('order__event__date_from'),
)
else:
raise ValueError(f'Unknown time type {values[0]}')

View File

@@ -56,6 +56,8 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
responses = register_data_exporters.send(event)
for receiver, response in responses:
if not response:
continue
ex = response(event, event.organizer, set_progress)
if ex.identifier == provider:
d = ex.render(form_data)

View File

@@ -102,7 +102,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
payment = ""
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
if payment:
payment += "<br />"
payment += "<br /><br />"
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
)

View File

@@ -35,6 +35,7 @@
import hashlib
import inspect
import logging
import mimetypes
import os
import re
import smtplib
@@ -51,6 +52,7 @@ from bs4 import BeautifulSoup
from celery import chain
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.mail import (
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
)
@@ -73,8 +75,9 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_ical
from pretix.presale.ical import get_private_icals
logger = logging.getLogger('pretix.base.mail')
INVALID_ADDRESS = 'invalid-pretix-mail-address'
@@ -94,7 +97,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
attach_ical=False, attach_cached_files: Sequence = None):
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -142,6 +145,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param attach_cached_files: A list of cached file to attach to this email.
:param attach_other_files: A list of file paths on our storage to attach.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
that the email has been sent, just that it has been queued by the email backend.
"""
@@ -212,7 +217,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
bcc.append(bcc_mail.strip())
if settings_holder.settings.mail_from == settings.DEFAULT_FROM_EMAIL and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
if settings_holder.settings.mail_from in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) \
and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = settings_holder.settings.contact_mail
prefix = settings_holder.settings.get('mail_prefix')
@@ -301,6 +307,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
organizer=organizer.pk if organizer else None,
customer=customer.pk if customer else None,
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
attach_other_files=attach_other_files,
)
if invoices:
@@ -338,7 +345,8 @@ class CustomEmail(EmailMultiAlternatives):
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None) -> bool:
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
attach_other_files: List[str] = None) -> bool:
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
@@ -422,18 +430,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
}
)
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])
for i, cal in enumerate(get_private_icals(event, [position] if position else order.positions.all())):
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
@@ -455,6 +452,20 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
logger.exception('Could not attach invoice to email')
pass
if attach_other_files:
for fname in attach_other_files:
ftype, _ = mimetypes.guess_type(fname)
data = default_storage.open(fname).read()
try:
email.attach(
clean_filename(os.path.basename(fname)),
data,
ftype
)
except:
logger.exception('Could not attach file to email')
pass
if attach_cached_files:
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
if cf.file:
@@ -568,7 +579,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
}
)
raise e
if logger:
if log_target:
log_target.log_action(
'pretix.email.error',
data={

View File

@@ -148,7 +148,7 @@ def send_notification_mail(notification: Notification, user: User):
),
'body': body_plain,
'html': body_html,
'sender': settings.MAIL_FROM,
'sender': settings.MAIL_FROM_NOTIFICATIONS,
'headers': {},
'user': user.pk
})

View File

@@ -700,7 +700,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
bundled_sum = 0
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
@@ -856,7 +856,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
total=total,
testmode=True if sales_channel.testmode_supported and event.testmode else False,
meta_info=json.dumps(meta_info or {}),
require_approval=any(p.item.require_approval for p in positions),
require_approval=any(p.requires_approval(invoice_address=address) for p in positions),
sales_channel=sales_channel.identifier,
customer=customer,
)
@@ -932,7 +932,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
invoice, payment: OrderPayment):
invoice, payment: OrderPayment, is_free=False):
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
email_subject = _('Your order: %(code)s') % {'code': order.code}
try:
@@ -941,24 +941,29 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
)
except SendMailException:
logger.exception('Order received email could not be sent')
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str):
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str, is_free=False):
email_context = get_email_context(event=event, order=order, position=position)
email_subject = _('Your event registration: %(code)s') % {'code': order.code}
try:
order.send_mail(
position.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[],
attach_tickets=True,
position=position,
attach_ical=event.settings.mail_attach_ical
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
)
except SendMailException:
logger.exception('Order received email could not be sent to attendee')
@@ -1064,11 +1069,13 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
email_attendees_template = event.settings.mail_text_order_placed_attendee
if sales_channel in event.settings.mail_sales_channel_placed_paid:
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment)
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment,
is_free=free_order_flow)
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)
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry,
is_free=free_order_flow)
return order.id
@@ -1586,21 +1593,22 @@ class OrderChangeManager:
op = opcache[a['addon_to']]
item = _items_cache[a['item']]
subevent = op.subevent # for now, we might lift this requirement later
variation = _variations_cache[a['variation']] if a['variation'] is not None else None
if item.category_id not in available_categories[op.pk]:
raise OrderError(error_messages['addon_invalid_base'])
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
quotas = list(item.quotas.filter(subevent=op.subevent)
if variation is None else variation.quotas.filter(subevent=op.subevent))
quotas = list(item.quotas.filter(subevent=subevent)
if variation is None else variation.quotas.filter(subevent=subevent))
if not quotas:
raise OrderError(error_messages['unavailable'])
if (a['item'], a['variation']) in input_addons[op.id]:
raise OrderError(error_messages['addon_duplicate_item'])
if item.require_voucher or op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher):
if item.require_voucher or item.hide_without_voucher or (variation and variation.hide_without_voucher):
raise OrderError(error_messages['voucher_required'])
if not item.is_available() or (variation and not variation.is_available()):
@@ -1610,11 +1618,11 @@ class OrderChangeManager:
variation and self.order.sales_channel not in variation.sales_channels):
raise OrderError(error_messages['unavailable'])
if op.subevent and item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
raise OrderError(error_messages['not_for_sale'])
if op.subevent and variation and variation.pk in op.subevent.var_overrides and \
not op.subevent.var_overrides[variation.pk].is_available():
if subevent and variation and variation.pk in subevent.var_overrides and \
not subevent.var_overrides[variation.pk].is_available():
raise OrderError(error_messages['not_for_sale'])
if item.has_variations and not variation:
@@ -1623,10 +1631,10 @@ class OrderChangeManager:
if variation and variation.item_id != item.pk:
raise OrderError(error_messages['not_for_sale'])
if op.subevent and op.subevent.presale_start and now() < op.subevent.presale_start:
if subevent and subevent.presale_start and now() < subevent.presale_start:
raise OrderError(error_messages['not_started'])
if (op.subevent and op.subevent.presale_has_ended) or self.event.presale_has_ended:
if (subevent and subevent.presale_has_ended) or self.event.presale_has_ended:
raise OrderError(error_messages['ended'])
if item.require_bundling:
@@ -2065,7 +2073,7 @@ class OrderChangeManager:
split_order.code = None
split_order.datetime = now()
split_order.secret = generate_secret()
split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions)
split_order.require_approval = self.order.require_approval and any(p.requires_approval(invoice_address=self._invoice_address) for p in split_positions)
split_order.save()
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
'original_order': self.order.code

View File

@@ -113,10 +113,8 @@ class QuotaAvailability:
be a few minutes outdated. In this case, you may not rely on the results in the ``count_*`` properties.
"""
now_dt = now_dt or now()
quotas = list(set(self._queue))
quotas_original = list(self._queue)
self._queue.clear()
if not quotas:
quota_ids_set = {q.id for q in self._queue}
if not quota_ids_set:
return
if allow_cache:
@@ -129,7 +127,7 @@ class QuotaAvailability:
elif settings.HAS_REDIS:
rc = get_redis_connection("redis")
quotas_by_event = defaultdict(list)
for q in quotas_original:
for q in [_q for _q in self._queue if _q.id in quota_ids_set]:
quotas_by_event[q.event_id].append(q)
for eventid, evquotas in quotas_by_event.items():
@@ -139,16 +137,19 @@ class QuotaAvailability:
data = [rv for rv in redisval.decode().split(',')]
# Except for some rare situations, we don't want to use cache entries older than 2 minutes
if time.time() - int(data[2]) < 120 or allow_cache_stale:
quotas_original.remove(q)
quotas.remove(q)
quota_ids_set.remove(q.id)
if data[1] == "None":
self.results[q] = int(data[0]), None
else:
self.results[q] = int(data[0]), int(data[1])
if not quotas:
if not quota_ids_set:
return
quotas = [_q for _q in self._queue if _q.id in quota_ids_set]
quotas_original = list(quotas)
self._queue.clear()
self._compute(quotas, now_dt)
for q in quotas_original:
@@ -284,15 +285,16 @@ class QuotaAvailability:
seq = Q(subevent_id__in=subevents)
if None in subevents:
seq |= Q(subevent__isnull=True)
quota_ids = {q.pk for q in quotas}
op_lookup = OrderPosition.objects.filter(
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
order__event_id__in=events,
).filter(seq).filter(
Q(
Q(variation_id__isnull=True) &
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
) | Q(
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas})
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
).order_by()
if any(q.release_after_exit for q in quotas):
op_lookup = op_lookup.annotate(
@@ -359,6 +361,7 @@ class QuotaAvailability:
func = 'GREATEST'
subevents = {q.subevent_id for q in quotas}
quota_ids = {q.pk for q in quotas}
seq = Q(subevent_id__in=subevents)
if None in subevents:
seq |= Q(subevent__isnull=True)
@@ -370,10 +373,9 @@ class QuotaAvailability:
Q(
Q(
Q(variation_id__isnull=True) &
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
) | Q(
variation_id__in={i['itemvariation_id'] for i in q_vars if
self._quota_objects[i['quota_id']] in quotas}
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
) | Q(
quota_id__in=[q.pk for q in quotas]
)
@@ -398,6 +400,7 @@ class QuotaAvailability:
def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt):
events = {q.event_id for q in quotas}
subevents = {q.subevent_id for q in quotas}
quota_ids = {q.pk for q in quotas}
seq = Q(subevent_id__in=subevents)
if None in subevents:
seq |= Q(subevent__isnull=True)
@@ -413,9 +416,9 @@ class QuotaAvailability:
Q(
Q(
Q(variation_id__isnull=True) &
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
) | Q(
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas}
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
)
)
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
@@ -434,6 +437,7 @@ class QuotaAvailability:
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
events = {q.event_id for q in quotas}
subevents = {q.subevent_id for q in quotas}
quota_ids = {q.pk for q in quotas}
seq = Q(subevent_id__in=subevents)
if None in subevents:
seq |= Q(subevent__isnull=True)
@@ -444,9 +448,8 @@ class QuotaAvailability:
Q(
Q(
Q(variation_id__isnull=True) &
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})
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
)
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
for line in w_lookup:

View File

@@ -45,7 +45,7 @@ def validate_plan_change(event, subevent, plan):
seat=OuterRef('pk'),
canceled=False,
).exclude(
order__status=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
))
).annotate(has_v=Count('vouchers')).filter(
subevent=subevent,
@@ -69,7 +69,7 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
seat=OuterRef('pk'),
canceled=False,
).exclude(
order__status=Order.STATUS_CANCELED
order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
)),
has_v=Count('vouchers')
).filter(subevent=subevent).order_by():
@@ -134,7 +134,7 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
Seat.objects.bulk_create(create_seats)
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
OrderPosition.all.filter(
Q(canceled=True) | Q(order__status=Order.STATUS_CANCELED),
Q(canceled=True) | Q(order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)),
seat__in=[s.pk for s in current_seats.values()],
).update(seat=None)
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()

View File

@@ -94,6 +94,25 @@ def primary_font_kwargs():
}
def restricted_plugin_kwargs():
from pretix.base.plugins import get_all_plugins
plugins_available = [
(p.module, p.name) for p in get_all_plugins(None)
if (
not p.name.startswith('.') and
getattr(p, 'visible', True) and
getattr(p, 'restricted', False) and
not hasattr(p, 'is_available') # this means you should not really use restricted and is_available
)
]
return {
'widget': forms.CheckboxSelectMultiple,
'label': _("Allow usage of restricted plugins"),
'choices': plugins_available,
}
class LazyI18nStringList(UserList):
def __init__(self, init_list=None):
super().__init__()
@@ -109,6 +128,13 @@ class LazyI18nStringList(UserList):
DEFAULTS = {
'allowed_restricted_plugins': {
'default': [],
'type': list,
'form_class': forms.MultipleChoiceField,
'serializer_class': serializers.MultipleChoiceField,
'form_kwargs': lambda: restricted_plugin_kwargs(),
},
'customer_accounts': {
'default': 'False',
'type': bool,
@@ -136,11 +162,15 @@ DEFAULTS = {
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(
min_value=1,
),
'form_kwargs': dict(
min_value=1,
required=True,
label=_("Maximum number of items per order"),
help_text=_("Add-on products will not be counted.")
)
),
},
'display_net_prices': {
'default': 'False',
@@ -368,11 +398,12 @@ DEFAULTS = {
'form_class': I18nFormField,
'serializer_class': I18nField,
'form_kwargs': dict(
label=_("Custom address field"),
label=_("Custom recipient field"),
widget=I18nTextInput,
help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to "
"your invoice address form, please fill in the label here. This label will both be used for "
"asking the user to input their details as well as for displaying the value on the invoice. "
"asking the user to input their details as well as for displaying the value on the invoice. It will "
"be shown on the invoice below the headline. "
"The field will not be required.")
)
},
@@ -440,9 +471,11 @@ DEFAULTS = {
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(),
'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."),
required=True,
)
},
'invoice_numbers_consecutive': {
@@ -506,6 +539,7 @@ DEFAULTS = {
MinValueValidator(12),
MaxValueValidator(64),
],
required=True,
widget=forms.NumberInput(
attrs={
'min': '12',
@@ -520,9 +554,13 @@ DEFAULTS = {
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(
min_value=0,
),
'form_kwargs': dict(
min_value=0,
label=_("Reservation period"),
required=True,
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
)
},
@@ -577,6 +615,7 @@ DEFAULTS = {
'form_kwargs': dict(
label=_("Set payment term"),
widget=forms.RadioSelect,
required=True,
choices=(
('days', _("in days")),
('minutes', _("in minutes"))
@@ -1091,9 +1130,13 @@ DEFAULTS = {
'type': int,
'serializer_class': serializers.IntegerField,
'form_class': forms.IntegerField,
'serializer_kwargs': dict(
min_value=1,
),
'form_kwargs': dict(
label=_("Waiting list response time"),
min_value=1,
required=True,
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(),
@@ -1556,6 +1599,32 @@ DEFAULTS = {
help_text=_("If enabled, we will attach an .ics calendar file to order confirmation emails."),
)
},
'mail_attach_ical_paid_only': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Attach calendar files only after order has been paid"),
help_text=_("Use this if you e.g. put a private access link into the calendar file to make sure people only "
"receive it after their payment was confirmed."),
)
},
'mail_attach_ical_description': {
'default': '',
'type': LazyI18nString,
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Event description"),
widget=I18nTextarea,
help_text=_(
"You can use this to share information with your attendees, such as travel information or the link to a digital event. "
"If you keep it empty, we will put a link to the event shop, the admission time, and your organizer name in there. "
"We do not allow using placeholders with sensitive person-specific data as calendar entries are often shared with an "
"unspecified number of people."
),
)
},
'mail_prefix': {
'default': None,
'type': str,
@@ -1572,7 +1641,7 @@ DEFAULTS = {
'type': str
},
'mail_from': {
'default': settings.MAIL_FROM,
'default': settings.MAIL_FROM_ORGANIZERS,
'type': str,
'form_class': forms.EmailField,
'serializer_class': serializers.EmailField,
@@ -1687,6 +1756,30 @@ You can change your order details and view the status of your order at
Best regards,
Your {event} team"""))
},
'mail_attachment_new_order': {
'default': None,
'type': File,
'form_class': ExtFileField,
'form_kwargs': dict(
label=_('Attachment for new orders'),
ext_whitelist=(".pdf",),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
help_text=_('This file will be attached to the first email that we send for every new order. Therefore it will be '
'combined with the "Placed order", "Free order", or "Received order" texts from above. It will be sent '
'to both order contacts and attendees. You can use this e.g. to send your terms of service. Do not use '
'it to send non-public information as this file might be sent before payment is confirmed or the order '
'is approved. To avoid this vital email going to spam, you can only upload PDF files of up to {size} MB.').format(
size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT // (1024 * 1024),
)
),
'serializer_class': UploadedFileField,
'serializer_kwargs': dict(
allowed_types=[
'application/pdf'
],
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
)
},
'mail_send_order_placed_attendee': {
'type': bool,
'default': 'False'

View File

@@ -34,7 +34,6 @@
import json
import os
from datetime import timedelta
from typing import List, Tuple
from django.db import transaction
@@ -70,11 +69,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=30):
return _('Your event needs to be over for at least 30 days to use this feature.')
if max_date is not None and max_date >= now():
return _('Your event needs to be over to use this feature.')
else:
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.date_to or event.date_from) >= now():
return _('Your event needs to be over to use this feature.')
if event.live:
return _('Your ticket shop needs to be offline to use this feature.')
return None

View File

@@ -0,0 +1,26 @@
{% extends "error.html" %}
{% load i18n %}
{% load rich_text %}
{% load static %}
{% block title %}{% trans "Redirect" %}{% endblock %}
{% block content %}
<i class="fa fa-link fa-fw big-icon"></i>
<div class="error-details">
<h1>{% trans "Redirect" %}</h1>
<h3>
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
{% endblocktrans %}
{% blocktrans trimmed %}
Please only proceed if you trust this website to be safe.
{% endblocktrans %}
</h3>
<p>
<a href="{{ url }}" class="btn btn-primary btn-lg">
{% blocktrans trimmed with host=hostname %}
Proceed to {{ host }}
{% endblocktrans %}
</a>
</p>
</div>
{% endblock %}

View File

@@ -30,67 +30,85 @@ from django.utils.translation import gettext as _
from django.views.decorators.csrf import requires_csrf_token
from sentry_sdk import last_event_id
from pretix.base.i18n import language
from pretix.base.middleware import get_language_from_request
def csrf_failure(request, reason=""):
t = get_template('csrffail.html')
c = {
'reason': reason,
'no_referer': reason == REASON_NO_REFERER,
'no_referer1': _(
"You are seeing this message because this HTTPS site requires a "
"'Referer header' to be sent by your Web browser, but none was "
"sent. This header is required for security reasons, to ensure "
"that your browser is not being hijacked by third parties."),
'no_referer2': _(
"If you have configured your browser to disable 'Referer' headers, "
"please re-enable them, at least for this site, or for HTTPS "
"connections, or for 'same-origin' requests."),
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
'no_cookie1': _(
"You are seeing this message because this site requires a CSRF "
"cookie when submitting forms. This cookie is required for "
"security reasons, to ensure that your browser is not being "
"hijacked by third parties."),
'no_cookie2': _(
"If you have configured your browser to disable cookies, please "
"re-enable them, at least for this site, or for 'same-origin' "
"requests."),
}
return HttpResponseForbidden(t.render(c), content_type='text/html')
try:
locale = get_language_from_request(request)
except:
locale = "en"
with language(locale): # Middleware might not have run, need to do this manually
t = get_template('csrffail.html')
c = {
'reason': reason,
'no_referer': reason == REASON_NO_REFERER,
'no_referer1': _(
"You are seeing this message because this HTTPS site requires a "
"'Referer header' to be sent by your Web browser, but none was "
"sent. This header is required for security reasons, to ensure "
"that your browser is not being hijacked by third parties."),
'no_referer2': _(
"If you have configured your browser to disable 'Referer' headers, "
"please re-enable them, at least for this site, or for HTTPS "
"connections, or for 'same-origin' requests."),
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
'no_cookie1': _(
"You are seeing this message because this site requires a CSRF "
"cookie when submitting forms. This cookie is required for "
"security reasons, to ensure that your browser is not being "
"hijacked by third parties."),
'no_cookie2': _(
"If you have configured your browser to disable cookies, please "
"re-enable them, at least for this site, or for 'same-origin' "
"requests."),
}
return HttpResponseForbidden(t.render(c), content_type='text/html')
@requires_csrf_token
def page_not_found(request, exception):
exception_repr = exception.__class__.__name__
# Try to get an "interesting" exception message, if any (and not the ugly
# Resolver404 dictionary)
try:
message = exception.args[0]
except (AttributeError, IndexError):
pass
else:
if isinstance(message, (str, Promise)):
exception_repr = str(message)
context = {
'request_path': request.path,
'exception': exception_repr,
}
template = get_template('404.html')
body = template.render(context, request)
r = HttpResponseNotFound(body)
r.xframe_options_exempt = True
return r
locale = get_language_from_request(request)
except:
locale = "en"
with language(locale): # Middleware might not have run, need to do this manually
exception_repr = exception.__class__.__name__
# Try to get an "interesting" exception message, if any (and not the ugly
# Resolver404 dictionary)
try:
message = exception.args[0]
except (AttributeError, IndexError):
pass
else:
if isinstance(message, (str, Promise)):
exception_repr = str(message)
context = {
'request_path': request.path,
'exception': exception_repr,
}
template = get_template('404.html')
body = template.render(context, request)
r = HttpResponseNotFound(body)
r.xframe_options_exempt = True
return r
@requires_csrf_token
def server_error(request):
try:
template = loader.get_template('500.html')
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
r = HttpResponseServerError(template.render({
'request': request,
'sentry_event_id': last_event_id(),
}))
r.xframe_options_exempt = True
return r
locale = get_language_from_request(request)
except:
locale = "en"
with language(locale): # Middleware might not have run, need to do this manually
try:
template = loader.get_template('500.html')
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
r = HttpResponseServerError(template.render({
'request': request,
'sentry_event_id': last_event_id(),
}))
r.xframe_options_exempt = True
return r

View File

@@ -23,15 +23,38 @@ import urllib.parse
from django.core import signing
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
def _is_samesite_referer(request):
referer = request.META.get('HTTP_REFERER')
if referer is None:
return False
referer = urllib.parse.urlparse(referer)
# Make sure we have a valid URL for Referer.
if '' in (referer.scheme, referer.netloc):
return False
return (referer.scheme, referer.netloc) == (request.scheme, request.get_host())
def redir_view(request):
signer = signing.Signer(salt='safe-redirect')
try:
url = signer.unsign(request.GET.get('url', ''))
except signing.BadSignature:
return HttpResponseBadRequest('Invalid parameter')
if not _is_samesite_referer(request):
u = urllib.parse.urlparse(url)
return render(request, 'pretixbase/redirect.html', {
'hostname': u.hostname,
'url': url,
})
r = HttpResponseRedirect(url)
r['X-Robots-Tag'] = 'noindex'
return r

View File

@@ -205,6 +205,8 @@ class AsyncFormView(AsyncMixin, FormView):
Also, all form keyword arguments except ``instance`` need to be serializable.
"""
known_errortypes = ['ValidationError']
expected_exceptions = (ValidationError,)
task_base = ProfiledEventTask
def __init_subclass__(cls):
def async_execute(self, *, request_path, query_string, form_kwargs, locale, tz, organizer=None, event=None, user=None, session_key=None):
@@ -222,7 +224,7 @@ class AsyncFormView(AsyncMixin, FormView):
elif organizer:
view_instance.request.organizer = organizer
if user:
view_instance.request.user = User.objects.get(pk=user)
view_instance.request.user = User.objects.get(pk=user) if isinstance(user, int) else user
if session_key:
engine = import_module(settings.SESSION_ENGINE)
self.SessionStore = engine.SessionStore
@@ -231,7 +233,7 @@ class AsyncFormView(AsyncMixin, FormView):
with translation.override(locale), timezone.override(pytz.timezone(tz)):
form_class = view_instance.get_form_class()
if form_kwargs.get('instance'):
cls.model.objects.get(pk=form_kwargs['instance'])
form_kwargs['instance'] = cls.model.objects.get(pk=form_kwargs['instance'])
form_kwargs = view_instance.get_async_form_kwargs(form_kwargs, organizer, event)
form = form_class(**form_kwargs)
@@ -239,10 +241,10 @@ class AsyncFormView(AsyncMixin, FormView):
return view_instance.async_form_valid(self, form)
cls.async_execute = app.task(
base=ProfiledEventTask,
base=cls.task_base,
bind=True,
name=cls.__module__ + '.' + cls.__name__ + '.async_execute',
throws=(ValidationError,)
throws=cls.expected_exceptions
)(async_execute)
def async_form_valid(self, task, form):

View File

@@ -48,7 +48,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_scopes.forms import SafeModelMultipleChoiceField
from ...base.forms import I18nModelForm, SecretKeySettingsField
from ...base.forms import I18nModelForm
# Import for backwards compatibility with okd import paths
from ...base.forms.widgets import ( # noqa
@@ -373,49 +373,6 @@ class FontSelect(forms.RadioSelect):
option_template_name = 'pretixcontrol/font_option.html'
class SMTPSettingsMixin(forms.Form):
smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"),
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
required=False
)
smtp_host = forms.CharField(
label=_("Hostname"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
)
smtp_port = forms.IntegerField(
label=_("Port"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
)
smtp_username = forms.CharField(
label=_("Username"),
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
required=False
)
smtp_password = SecretKeySettingsField(
label=_("Password"),
required=False,
)
smtp_use_tls = forms.BooleanField(
label=_("Use STARTTLS"),
help_text=_("Commonly enabled on port 587."),
required=False
)
smtp_use_ssl = forms.BooleanField(
label=_("Use SSL"),
help_text=_("Commonly enabled on port 465."),
required=False
)
def clean(self):
data = super().clean()
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
return data
class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
def label_from_instance(self, obj):
return str(obj) if obj.active else mark_safe(f'<strike class="text-muted">{escape(obj)}</strike>')

View File

@@ -43,6 +43,7 @@ from django.core.validators import validate_email
from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import CheckboxSelectMultiple, formset_factory
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name
@@ -63,7 +64,7 @@ from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
)
from pretix.control.forms import (
MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField,
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2
@@ -534,34 +535,39 @@ class EventSettingsForm(SettingsForm):
'og_image',
]
def _resolve_virtual_keys_input(self, data, prefix=''):
# set all dependants of virtual_keys and
# delete all virtual_fields to prevent them from being saved
for virtual_key in self.virtual_keys:
if prefix + virtual_key not in data:
continue
base_key = prefix + virtual_key.rsplit('_', 2)[0]
asked_key = base_key + '_asked'
required_key = base_key + '_required'
if data[prefix + virtual_key] == 'optional':
data[asked_key] = True
data[required_key] = False
elif data[prefix + virtual_key] == 'required':
data[asked_key] = True
data[required_key] = True
# Explicitly check for 'do_not_ask'.
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
elif data[prefix + virtual_key] == 'do_not_ask':
data[asked_key] = False
data[required_key] = False
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
if not prefix:
data[virtual_key] = None
return data
def clean(self):
data = super().clean()
settings_dict = self.event.settings.freeze()
settings_dict.update(data)
# set all dependants of virtual_keys and
# delete all virtual_fields to prevent them from being saved
for virtual_key in self.virtual_keys:
if virtual_key not in data:
continue
base_key = virtual_key.rsplit('_', 2)[0]
asked_key = base_key + '_asked'
required_key = base_key + '_required'
if data[virtual_key] == 'optional':
data[asked_key] = True
data[required_key] = False
elif data[virtual_key] == 'required':
data[asked_key] = True
data[required_key] = True
# Explicitly check for 'do_not_ask'.
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
elif data[virtual_key] == 'do_not_ask':
data[asked_key] = False
data[required_key] = False
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
data[virtual_key] = None
data = self._resolve_virtual_keys_input(data)
validate_event_settings(self.event, data)
return data
@@ -621,6 +627,35 @@ class EventSettingsForm(SettingsForm):
else:
self.initial[virtual_key] = 'do_not_ask'
@cached_property
def changed_data(self):
data = []
# We need to resolve the mapping between our "virtual" fields and the "real"fields here, otherwise
# they are detected as "changed" on every save even though they aren't.
in_data = self._resolve_virtual_keys_input(self.data.copy(), prefix=f'{self.prefix}-' if self.prefix else '')
for name, field in self.fields.items():
prefixed_name = self.add_prefix(name)
data_value = field.widget.value_from_datadict(in_data, self.files, prefixed_name)
if not field.show_hidden_initial:
# Use the BoundField's initial as this is the value passed to
# the widget.
initial_value = self[name].initial
else:
initial_prefixed_name = self.add_initial_prefix(name)
hidden_widget = field.hidden_widget()
try:
initial_value = field.to_python(hidden_widget.value_from_datadict(
self.data, self.files, initial_prefixed_name))
except ValidationError:
# Always assume data has changed if validation fails.
data.append(name)
continue
if field.has_changed(initial_value, data_value):
data.append(name)
return data
class CancelSettingsForm(SettingsForm):
auto_fields = [
@@ -830,13 +865,15 @@ def contains_web_channel_validate(val):
raise ValidationError(_("The online shop must be selected to receive these emails."))
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
class MailSettingsForm(SettingsForm):
auto_fields = [
'mail_prefix',
'mail_from',
'mail_from_name',
'mail_attach_ical',
'mail_attach_tickets',
'mail_attachment_new_order',
'mail_attach_ical_paid_only',
'mail_attach_ical_description',
]
mail_sales_channel_placed_paid = forms.MultipleChoiceField(
@@ -1044,7 +1081,8 @@ class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
'mail_text_download_reminder_attendee': ['event', 'order', 'position'],
'mail_text_resend_link': ['event', 'order'],
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
'mail_text_resend_all_links': ['event', 'orders']
'mail_text_resend_all_links': ['event', 'orders'],
'mail_attach_ical_description': ['event', 'event_or_subevent'],
}
def _set_field_placeholders(self, fn, base_parameters):
@@ -1179,6 +1217,7 @@ class TaxRuleLineForm(I18nForm):
('reverse', _('Reverse charge')),
('no', _('No VAT')),
('block', _('Sale not allowed')),
('require_approval', _('Order requires approval')),
],
)
rate = forms.DecimalField(
@@ -1212,7 +1251,7 @@ TaxRuleLineFormSet = formset_factory(
class TaxRuleForm(I18nModelForm):
class Meta:
model = TaxRule
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country']
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes']
class WidgetCodeForm(forms.Form):

View File

@@ -1881,7 +1881,7 @@ class VoucherFilterForm(FilterForm):
if s == '<>':
qs = qs.filter(Q(tag__isnull=True) | Q(tag=''))
elif s[0] == '"' and s[-1] == '"':
qs = qs.filter(tag__iexact=s[1:-1])
qs = qs.filter(tag__exact=s[1:-1])
else:
qs = qs.filter(tag__icontains=s)

View File

@@ -627,7 +627,9 @@ class ItemUpdateForm(I18nModelForm):
'class': 'scrolling-multiple-choice'
}),
'generate_tickets': TicketNullBooleanSelect(),
'show_quota_left': ShowQuotaNullBooleanSelect()
'show_quota_left': ShowQuotaNullBooleanSelect(),
'max_per_order': forms.widgets.NumberInput(attrs={'min': 0}),
'min_per_order': forms.widgets.NumberInput(attrs={'min': 0}),
}
@@ -713,6 +715,7 @@ class ItemVariationForm(I18nModelForm):
'default_price',
'original_price',
'description',
'require_approval',
'require_membership',
'require_membership_hidden',
'require_membership_types',

View File

@@ -0,0 +1,129 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import ipaddress
import socket
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from pretix.base.forms import SecretKeySettingsField, SettingsForm
class SMTPMailForm(SettingsForm):
mail_from = forms.EmailField(
label=_("Sender address"),
help_text=_("Sender address for outgoing emails"),
required=True,
)
smtp_host = forms.CharField(
label=_("Hostname"),
required=True,
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
)
smtp_port = forms.IntegerField(
label=_("Port"),
required=True,
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
)
smtp_username = forms.CharField(
label=_("Username"),
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
required=False
)
smtp_password = SecretKeySettingsField(
label=_("Password"),
required=False,
)
smtp_use_tls = forms.BooleanField(
label=_("Use STARTTLS"),
help_text=_("Commonly enabled on port 587."),
required=False
)
smtp_use_ssl = forms.BooleanField(
label=_("Use SSL"),
help_text=_("Commonly enabled on port 465."),
required=False
)
def clean(self):
data = super().clean()
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
for k, v in self.fields.items():
val = data.get(k)
if v._required and not val:
self.add_error(k, _('This field is required.'))
return data
def clean_smtp_host(self):
v = self.cleaned_data['smtp_host']
if not settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS:
try:
if ipaddress.ip_address(v).is_private:
raise ValidationError(_('You are not allowed to use this mail server, please choose one with a '
'public IP address instead.'))
except ValueError:
try:
if ipaddress.ip_address(socket.gethostbyname(v)).is_private:
raise ValidationError(_('You are not allowed to use this mail server, please choose one with a '
'public IP address instead.'))
except OSError:
raise ValidationError(_('We were unable to resolve this hostname.'))
return v
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.obj.settings.mail_from in (settings.MAIL_FROM, settings.MAIL_FROM_ORGANIZERS):
self.initial.pop('mail_from')
for k, v in self.fields.items():
v._required = v.required
v.required = False
v.widget.is_required = False
class SimpleMailForm(SettingsForm):
mail_from = forms.EmailField(
label=_("Sender address"),
help_text=_("Sender address for outgoing emails"),
required=True,
)
def clean(self):
cleaned_data = super().clean()
for k, v in self.fields.items():
val = cleaned_data.get(k)
if v._required and not val:
self.add_error(k, _('This field is required.'))
return cleaned_data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.obj.settings.mail_from in (settings.MAIL_FROM, settings.MAIL_FROM_ORGANIZERS):
self.initial.pop('mail_from')
for k, v in self.fields.items():
v._required = v.required
v.required = False
v.widget.is_required = False

View File

@@ -452,7 +452,7 @@ class OrderPositionChangeForm(forms.Form):
@staticmethod
def taxrule_label_from_instance(obj):
return f"{obj.name} ({obj.rate} %)"
return f"{obj.internal_name or obj.name} ({obj.rate} %)"
def __init__(self, *args, **kwargs):
instance = kwargs.pop('instance')
@@ -612,7 +612,7 @@ class OrderMailForm(forms.Form):
)
attach_tickets = forms.BooleanField(
label=_("Attach tickets"),
help_text=_("Will be ignored if all tickets in this order exceed a given size limit to ensure email deliverability."),
help_text=_("Will be ignored if tickets exceed a given size limit to ensure email deliverability."),
required=False
)
attach_invoices = forms.ModelMultipleChoiceField(

View File

@@ -44,21 +44,23 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea
from phonenumber_field.formfields import PhoneNumberField
from pytz import common_timezones
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.forms.questions import NamePartsFormField
from pretix.base.forms.questions import (
NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale,
get_phone_prefix,
)
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
MembershipType, Organizer, Team,
)
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
from pretix.control.forms import (
ExtFileField, SMTPSettingsMixin, SplitDateTimeField,
)
from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import (
SafeEventMultipleChoiceField, multimail_validate,
)
@@ -284,6 +286,7 @@ class OrganizerSettingsForm(SettingsForm):
required=False,
)
auto_fields = [
'allowed_restricted_plugins',
'customer_accounts',
'customer_accounts_link_by_email',
'invoice_regenerate_allowed',
@@ -337,7 +340,12 @@ class OrganizerSettingsForm(SettingsForm):
)
def __init__(self, *args, **kwargs):
is_admin = kwargs.pop('is_admin', False)
super().__init__(*args, **kwargs)
if not is_admin:
del self.fields['allowed_restricted_plugins']
self.fields['name_scheme'].choices = (
(k, _('Ask for {fields}, display like {example}').format(
fields=' + '.join(str(vv[1]) for vv in v['fields']),
@@ -354,9 +362,8 @@ class OrganizerSettingsForm(SettingsForm):
]
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
class MailSettingsForm(SettingsForm):
auto_fields = [
'mail_from',
'mail_from_name',
]
@@ -421,6 +428,7 @@ class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
if f == 'full_name':
continue
placeholders['name_%s' % f] = name_scheme['sample'][f]
placeholders['name_for_salutation'] = _("Mr Doe")
return placeholders
def _set_field_placeholders(self, fn, base_parameters):
@@ -535,11 +543,21 @@ class CustomerUpdateForm(forms.ModelForm):
class Meta:
model = Customer
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'locale']
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'phone', 'locale']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.instance.phone and (self.instance.organizer.settings.region or self.instance.locale):
country_code = self.instance.organizer.settings.region or get_country_by_locale(self.instance.locale)
phone_prefix = get_phone_prefix(country_code)
if phone_prefix:
self.initial['phone'] = "+{}.".format(phone_prefix)
self.fields['phone'] = PhoneNumberField(
label=_('Phone'),
required=False,
widget=WrappedPhoneNumberPrefixWidget()
)
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=False,

View File

@@ -43,7 +43,6 @@ from django.urls import get_script_prefix, resolve, reverse
from django.utils.encoding import force_str
from django.utils.translation import gettext as _
from django_scopes import scope
from hijack.templatetags.hijack_tags import is_hijacked
from pretix.base.models import Event, Organizer
from pretix.base.models.auth import SuperuserPermissionSet, User
@@ -183,7 +182,7 @@ class AuditLogMiddleware:
def __call__(self, request):
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
if is_hijacked(request):
if getattr(request.user, "is_hijacked", False):
hijack_history = request.session.get('hijack_history', False)
hijacker = get_object_or_404(User, pk=hijack_history[0])
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))

View File

@@ -1,6 +1,5 @@
{% load compress %}
{% load i18n %}
{% load hijack_tags %}
{% load static %}
<!DOCTYPE html>
<html{% if rtl %} dir="rtl" class="rtl"{% endif %}>
@@ -39,7 +38,7 @@
</div>
{% endfor %}
{% endif %}
{% if request|is_hijacked %}
{% if request.user.is_hijacked %}
<div class="impersonate-warning">
<span class="fa fa-user-secret"></span>
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}

View File

@@ -1,7 +1,6 @@
{% load compress %}
{% load static %}
{% load i18n %}
{% load hijack_tags %}
{% load statici18n %}
{% load eventsignal %}
{% load eventurl %}
@@ -351,7 +350,7 @@
</ul>
</div>
{% endif %}
{% if request|is_hijacked %}
{% if request.user.is_hijacked %}
<div class="impersonate-warning">
<span class="fa fa-user-secret"></span>
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}

View File

@@ -74,17 +74,17 @@
{{ c.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if c.type == "exit" %}
{% if c.auto_checked_in %}
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip_html"
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip"
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
{% endif %}
{% elif c.forced and c.successful %}
<span class="fa fa-fw fa-warning" data-toggle="tooltip_html"
<span class="fa fa-fw fa-warning" data-toggle="tooltip"
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
{% elif c.forced and not c.successful %}
<br>
<small class="text-muted">{% trans "Failed in offline mode" %}</small>
{% elif c.auto_checked_in %}
<span class="fa fa-fw fa-magic" data-toggle="tooltip_html"
<span class="fa fa-fw fa-magic" data-toggle="tooltip"
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}"></span>
{% endif %}
</td>

View File

@@ -0,0 +1,14 @@
{% load i18n %}{% blocktrans with code=code instance=instance %}Hello,
someone requested to use {{ address }} as a sender address on {{ instance }}.
This will allow them to send emails that are shown to originate from this email address.
If that was you, please enter the following confirmation code:
{{ code }}
If this was not requested by you, you can safely ignore this email.
Best regards,
Your {{ instance }} team
{% endblocktrans %}

View File

@@ -0,0 +1,127 @@
{% extends basetpl %}
{% load i18n %}
{% load bootstrap3 %}
{% load hierarkey_form %}
{% load static %}
{% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %}
<h1>{% trans "E-mail sending" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<div class="panel-group" id="email">
<div class="panel panel-default">
<div class="accordion-radio">
<div class="panel-heading">
<p class="panel-title">
<input type="radio" name="mode" value="system"
data-parent="#email"
{% if mode == "system" %}checked="checked"{% endif %}
id="input_mode_system"
data-toggle="radiocollapse" data-target="#mode_system"/>
<label for="input_mode_system"><strong>{% trans "Use system default" %}</strong></label>
</p>
</div>
</div>
<div id="mode_system"
class="panel-collapse collapsed {% if mode == "system" %}in{% endif %}">
<div class="panel-body form-horizontal">
<p>
{% blocktrans trimmed %}
E-mails will be sent through the system's default server. They will show the following
sender information:
{% endblocktrans %}
</p>
<dl class="dl-horizontal">
<dt>{% trans "From" context "mail_header" %}</dt>
<dd>{{ object.settings.mail_from_name|default_if_none:object.name }}
&lt;{{ default_sender_address }}&gt;
</dd>
{% if object.settings.contact_mail %}
<dt>{% trans "Reply-To" context "mail_header" %}</dt>
<dd>{{ object.settings.contact_mail }}</dd>
{% endif %}
</dl>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="accordion-radio">
<div class="panel-heading">
<p class="panel-title">
<input type="radio" name="mode" value="simple"
data-parent="#email"
{% if mode == "simple" %}checked="checked"{% endif %}
id="input_mode_simple"
data-toggle="radiocollapse" data-target="#mode_simple"/>
<label for="input_mode_simple"><strong>{% trans "Use system email server with a custom sender address" %}</strong></label>
</p>
</div>
</div>
<div id="mode_simple"
class="panel-collapse collapsed {% if mode == "simple" %}in{% endif %}">
<div class="panel-body form-horizontal">
<p>
{% blocktrans trimmed %}
E-mails will be sent through the system's default server but with your own sender
address.
This will make your emails look more personalized and coming directly from you, but it
also might require some extra steps to ensure good deliverability.
{% endblocktrans %}
</p>
{% bootstrap_form simple_form layout="control" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="accordion-radio">
<div class="panel-heading">
<p class="panel-title">
<input type="radio" name="mode" value="smtp"
data-parent="#email"
{% if mode == "smtp" %}checked="checked"{% endif %}
id="input_mode_smtp"
data-toggle="radiocollapse" data-target="#mode_smtp"/>
<label for="input_mode_smtp"><strong>{% trans "Use a custom SMTP server" %}</strong></label>
</p>
</div>
</div>
<div id="mode_smtp"
class="panel-collapse collapsed {% if mode == "smtp" %}in{% endif %}">
<div class="panel-body form-horizontal">
<p>
{% blocktrans trimmed %}
For full customization, you can configure your own SMTP server that will be used for
email sending.
{% endblocktrans %}
</p>
{% bootstrap_form smtp_form layout="control" %}
</div>
</div>
</div>
{% if request.event %}
<div class="panel panel-default">
<div class="accordion-radio">
<div class="panel-heading">
<p class="panel-title">
<input type="radio" name="mode" value="reset"
data-parent="#reset"
id="input_mode_reset"
data-toggle="radiocollapse" data-target="#mode_reset"/>
<label for="input_mode_reset"><strong>{% trans "Reset to organizer settings" %}</strong></label>
</p>
</div>
</div>
<div id="mode_reset"
class="panel-collapse collapsed {% if mode == "reset" %}in{% endif %}">
</div>
</div>
{% endif %}
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Continue" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends basetpl %}
{% load i18n %}
{% load bootstrap3 %}
{% load hierarkey_form %}
{% load static %}
{% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %}
<h1>{% trans "E-mail sending" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% for k, v in request.POST.items %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}
<input type="hidden" name="state" value="save">
<div class="panel panel-default">
<div class="panel-heading">
<p class="panel-title">
<strong>{% trans "Use system email server with a custom sender address" %}</strong>
</p>
</div>
<div class="panel-body form-horizontal">
{% if spf_warning %}
<div class="alert alert-warning">
<p>
{{ spf_warning }}
</p>
{% if spf_record %}
<p>
{% trans "This is the SPF record we found on your domain:" %}
</p>
<pre><code>{{ spf_record }}</code></pre>
<p>
{% trans "To fix this, include the following part before the last word:" %}
</p>
<pre><code>{{ spf_key }}</code></pre>
{% else %}
<p>
{% trans "Your new SPF record could look like this:" %}
</p>
<pre><code>v=spf1 a mx {{ spf_key }} ~all</code></pre>
{% endif %}
<p>
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
</p>
</div>
{% elif spf_key %}
<div class="alert alert-success">
{% blocktrans trimmed %}
We found an SPF record on your domain that includes this system. Great!
{% endblocktrans %}
</div>
{% endif %}
{% if verification %}
<h3>{% trans "Verification" %}</h3>
<p>
{% blocktrans trimmed with recp=recp %}
We've sent an email to {{ recp }} with a confirmation code to verify that this email address
is owned by you. Please enter the verification code below:
{% endblocktrans %}
</p>
<div class="form-group">
<label class="col-md-3 control-label" for="id_verification">
{% trans "Verification code" %}
</label>
<div class="col-md-9">
<input type="text" name="verification" class="form-control">
</div>
</div>
{% endif %}
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends basetpl %}
{% load i18n %}
{% load bootstrap3 %}
{% load hierarkey_form %}
{% load static %}
{% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %}
<h1>{% trans "E-mail sending" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% for k, v in request.POST.items %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}
<input type="hidden" name="state" value="save">
<div class="panel panel-default">
<div class="panel-heading">
<p class="panel-title">
<strong>{% trans "Use a custom SMTP server" %}</strong>
</p>
</div>
<div class="panel-body form-horizontal">
<div class="alert alert-success">
{% blocktrans trimmed %}
A test connection to your SMTP server was successful. You can now save your new settings
to put them in use.
{% endblocktrans %}
</div>
{% if known_host_problem %}
<div class="alert alert-warning">
{{ known_host_problem }}
</div>
{% endif %}
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -12,18 +12,56 @@
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.mail_prefix layout="control" %}
{% bootstrap_field form.mail_attach_tickets layout="control" %}
{% url "control:organizer.settings.mail" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "mail_from" "mail_from_name" "mail_text_signature" "mail_bcc" %}
{% bootstrap_field form.mail_from layout="control" %}
{% propagated request.event org_url "mail_from" "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Sending method" %}
</label>
<div class="col-md-9 static-form-row-with-btn">
{% if request.event.settings.smtp_use_custom %}
{% trans "Custom SMTP server" %}: {{ request.event.settings.smtp_host }}
{% else %}
{% trans "System-provided email server" %}
{% endif %}
&nbsp;&nbsp;
<a href="{% url "control:event.settings.mail.setup" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Sender address" %}
</label>
<div class="col-md-9 static-form-row-with-btn">
{{ request.event.settings.mail_from }}
&nbsp;&nbsp;
<a href="{% url "control:event.settings.mail.setup" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</div>
</div>
{% endpropagated %}
{% propagated request.event org_url "mail_from_name" "mail_text_signature" "mail_bcc" %}
{% bootstrap_field form.mail_from_name layout="control" %}
{% bootstrap_field form.mail_text_signature layout="control" %}
{% bootstrap_field form.mail_bcc layout="control" %}
{% endpropagated %}
{% bootstrap_field form.mail_prefix layout="control" %}
{% bootstrap_field form.mail_attach_tickets layout="control" %}
{% bootstrap_field form.mail_attach_ical layout="control" %}
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Calendar invites" %}</legend>
{% bootstrap_field form.mail_attach_ical layout="control" %}
{% bootstrap_field form.mail_attach_ical_paid_only layout="control" %}
{% bootstrap_field form.mail_attach_ical_description layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "E-mail design" %}</legend>
<div class="row">
@@ -47,6 +85,7 @@
</fieldset>
<fieldset>
<legend>{% trans "E-mail content" %}</legend>
<h4>{% trans "Text" %}</h4>
<div class="panel-group" id="questions_group">
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
@@ -81,27 +120,14 @@
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_approved_free,mail_text_order_denied" %}
</div>
</fieldset>
<fieldset>
<legend>{% trans "SMTP settings" %}</legend>
{% propagated request.event org_url "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %}
{% bootstrap_field form.smtp_use_custom layout="control" %}
{% bootstrap_field form.smtp_host layout="control" %}
{% bootstrap_field form.smtp_port layout="control" %}
{% bootstrap_field form.smtp_username layout="control" %}
{% bootstrap_field form.smtp_password layout="control" %}
{% bootstrap_field form.smtp_use_tls layout="control" %}
{% bootstrap_field form.smtp_use_ssl layout="control" %}
{% endpropagated %}
<h4>{% trans "Attachments" %}</h4>
{% bootstrap_field form.mail_attachment_new_order layout="control" %}
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
<button type="submit" class="btn btn-default btn-save pull-left" name="test" value="1">
{% trans "Save and test custom SMTP connection" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -32,9 +32,9 @@
{% endblocktrans %}</p>
{% endif %}
<p>{{ plugin.description }}</p>
{% if plugin.restricted and not request.user.is_staff %}
{% if plugin.restricted and plugin.module not in request.event.settings.allowed_restricted_plugins %}
<span class="text-muted">
{% trans "This plugin needs to be enabled by a system administrator for your event." %}
{% trans "This plugin needs to be enabled by a system administrator for your account." %}
</span>
{% endif %}
{% if plugin.app.compatibility_errors %}
@@ -62,7 +62,7 @@
{% if plugin.app.compatibility_errors %}
<button class="btn disabled btn-block btn-default"
disabled="disabled">{% trans "Incompatible" %}</button>
{% elif plugin.restricted and not staff_session %}
{% elif plugin.restricted and plugin.module not in request.event.settings.allowed_restricted_plugins %}
<button class="btn disabled btn-block btn-default"
disabled="disabled">{% trans "Not available" %}</button>
{% elif plugin.module in plugins_active %}

View File

@@ -24,6 +24,7 @@
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.name layout="control" %}
{% bootstrap_field form.internal_name layout="control" %}
{% bootstrap_field form.rate addon_after="%" layout="control" %}
{% bootstrap_field form.price_includes_tax layout="control" %}
</fieldset>
@@ -39,6 +40,7 @@
</div>
{% bootstrap_field form.eu_reverse_charge layout="control" %}
{% bootstrap_field form.home_country layout="control" %}
{% bootstrap_field form.keep_gross_if_rate_changes layout="control" %}
<h3>{% trans "Custom taxation rules" %}</h3>
<div class="alert alert-warning">
{% blocktrans trimmed %}

View File

@@ -33,7 +33,7 @@
<tr>
<td>
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{{ tr.name }}
{{ tr.internal_name|default:tr.name }}
</a></strong>
</td>
<td>

View File

@@ -1,6 +1,6 @@
{% load i18n %}
<div class="quotabox availability" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name }}<br>{% blocktrans with date=q.cached_availability_time|date:"SHORT_DATETIME_FORMAT" %}Numbers as of {{ date }}{% endblocktrans %}">
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}<br>{% blocktrans with date=q.cached_availability_time|date:"SHORT_DATETIME_FORMAT" %}Numbers as of {{ date }}{% endblocktrans %}">
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-100">

View File

@@ -1,6 +1,6 @@
{% load i18n %}
<a class="quotabox" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">

View File

@@ -73,6 +73,7 @@
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
{% bootstrap_field form.hide_without_voucher layout="control" %}
{% bootstrap_field form.require_approval layout="control" %}
{% if form.require_membership %}
{% bootstrap_field form.require_membership layout="control" %}
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
@@ -144,6 +145,7 @@
{% bootstrap_field formset.empty_form.available_until layout="control" %}
{% bootstrap_field formset.empty_form.sales_channels layout="control" %}
{% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %}
{% bootstrap_field formset.empty_form.require_approval layout="control" %}
{% if formset.empty_form.require_membership %}
{% bootstrap_field formset.empty_form.require_membership layout="control" %}
<div data-display-dependency="#{{ formset.empty_form.require_membership.id_for_label }}">

View File

@@ -46,7 +46,7 @@
{% for c in cat_list %}
<tbody data-dnd-url="{% url "control:event.items.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for i in c.list %}
{% if forloop.counter0 == 0 and i.category %}<tr class="sortable-disabled"><th colspan="8" scope="colgroup" class="text-muted">{{ i.category.name }}</th></tr>{% endif %}
{% if forloop.counter0 == 0 and i.category %}<tr class="sortable-disabled"><th colspan="8" scope="colgroup" class="text-muted">{{ i.category }}</th></tr>{% endif %}
<tr data-dnd-id="{{ i.id }}" {% if not i.active %}class="row-muted"{% endif %}>
<td><strong>
{% if not i.active %}<strike>{% endif %}

View File

@@ -13,7 +13,7 @@
<ul>
{% for item in dependent %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{{ item.name }}</a>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{{ item }}</a>
</li>
{% endfor %}
</ul>

View File

@@ -145,7 +145,12 @@
<strong>{% trans "Tax rule" %}</strong>
</div>
<div class="col-sm-5">
{{ position.tax_rule.name }} ({{ position.tax_rule.rate }} %)
{% if position.tax_rule.internal_name %}
{{ position.tax_rule.internal_name }}
{% else %}
{{ position.tax_rule.name }}
{% endif %}
({{ position.tax_rule.rate }} %)
</div>
<div class="col-sm-4 field-container">
{% bootstrap_field position.form.tax_rule layout='inline' %}

View File

@@ -360,19 +360,19 @@
{% if line.checkins.all %}
{% for c in line.all_checkins.all %}
{% if not c.successful %}
<span class="fa fa-fw fa-exclamation-circle text-danger" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Denied scan: {{ date }}{% endblocktrans %}<br>{{ c.get_error_reason_display }}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
<span class="fa fa-fw fa-exclamation-circle text-danger" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Denied scan: {{ date }}{% endblocktrans %}<br>{{ c.get_error_reason_display }}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
{% elif c.type == "exit" %}
{% if c.auto_checked_in %}
<span class="fa fa-fw text-success fa-hourglass-end" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
<span class="fa fa-fw text-success fa-hourglass-end" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
{% else %}
<span class="fa fa-fw text-success fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
<span class="fa fa-fw text-success fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
{% endif %}
{% elif c.forced %}
<span class="fa fa-fw fa-warning text-warning" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
<span class="fa fa-fw fa-warning text-warning" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
{% elif c.auto_checked_in %}
<span class="fa fa-fw fa-magic text-success" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
<span class="fa fa-fw fa-magic text-success" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
{% else %}
<span class="fa fa-fw fa-check text-success" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
<span class="fa fa-fw fa-check text-success" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
{% endif %}
{% endfor %}
{% endif %}

View File

@@ -48,6 +48,10 @@
</dd>
<dt>{% trans "Name" %}</dt>
<dd>{{ customer.name }}</dd>
{% if customer.phone %}
<dt>{% trans "Phone" %}</dt>
<dd>{{ customer.phone }}</dd>
{% endif %}
<dt>{% trans "Locale" %}</dt>
<dd>{{ display_locale }}</dd>
<dt>{% trans "Registration date" %}</dt>

View File

@@ -36,6 +36,9 @@
{% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.organizer_info_text layout="control" %}
{% bootstrap_field sform.event_team_provisioning layout="control" %}
{% if sform.allowed_restricted_plugins %}
{% bootstrap_field sform.allowed_restricted_plugins layout="control" %}
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Organizer page" %}</legend>

View File

@@ -57,6 +57,7 @@
</div>
<form class="" method="post" action="">
{% csrf_token %}
<button type="submit" class="hidden">Add</button> <!-- Required because pressing enter in the text fields will submit the first button -->
<table class="panel-body table">
<thead>
<tr>
@@ -101,7 +102,7 @@
</td>
<td class="text-right form-inline">
<input type="text" class="form-control input-sm" placeholder="{% trans "Value" %}" name="value">
<button class="btn btn-primary">
<button type="submit" class="btn btn-primary">
<span class="fa fa-plus"></span>
</button>
</td>

View File

@@ -11,13 +11,45 @@
<h1>{% trans "E-mail settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
mail-preview-url="{% url "control:organizer.settings.mail.preview" organizer=request.organizer.slug %}">
mail-preview-url="{% url "control:organizer.settings.mail.preview" organizer=request.organizer.slug %}">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.mail_from layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Sending method" %}
</label>
<div class="col-md-9 static-form-row-with-btn">
{% if request.organizer.settings.smtp_use_custom %}
{% trans "Custom SMTP server" %}: {{ request.organizer.settings.smtp_host }}
{% else %}
{% trans "System-provided email server" %}
{% endif %}
&nbsp;&nbsp;
<a href="{% url "control:organizer.settings.mail.setup" organizer=request.organizer.slug %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Sender address" %}
</label>
<div class="col-md-9 static-form-row-with-btn">
{{ request.organizer.settings.mail_from }}
&nbsp;&nbsp;
<a href="{% url "control:organizer.settings.mail.setup" organizer=request.organizer.slug %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</div>
</div>
{% bootstrap_field form.mail_from_name layout="control" %}
{% bootstrap_field form.mail_text_signature layout="control" %}
{% bootstrap_field form.mail_bcc layout="control" %}
@@ -35,24 +67,11 @@
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_text_customer_reset" %}
</div>
</fieldset>
<fieldset>
<legend>{% trans "SMTP settings" %}</legend>
{% bootstrap_field form.smtp_use_custom layout="control" %}
{% bootstrap_field form.smtp_host layout="control" %}
{% bootstrap_field form.smtp_port layout="control" %}
{% bootstrap_field form.smtp_username layout="control" %}
{% bootstrap_field form.smtp_password layout="control" %}
{% bootstrap_field form.smtp_use_tls layout="control" %}
{% bootstrap_field form.smtp_use_ssl layout="control" %}
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
<button type="submit" class="btn btn-default btn-save pull-left" name="test" value="1">
{% trans "Save and test custom SMTP connection" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -38,6 +38,14 @@
<input name="text" value="{{ backend }}" class="form-control" disabled>
</div>
</div>
{% if user.auth_backend_identifier %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "External identifier" %}</label>
<div class="col-md-9">
<input name="text" value="{{ user.auth_backend_identifier }}" class="form-control" disabled>
</div>
</div>
{% endif %}
{% bootstrap_field form.email layout='control' %}
{% if form.new_pw %}
{% bootstrap_field form.new_pw layout='control' %}

View File

@@ -39,17 +39,15 @@
<legend>{% trans "Voucher details" %}</legend>
{% bootstrap_field form.code layout="control" %}
{% if voucher.pk %}
{% if not request.event.has_subevents or voucher.subevent %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_url">{% trans "Voucher link" %}</label>
<div class="col-md-9">
<input type="text" name="url"
value="{% abseventurl request.event "presale:event.redeem" %}?voucher={{ voucher.code|urlencode }}{% if voucher.subevent_id %}&subevent={{ voucher.subevent_id }}{% endif %}"
class="form-control"
id="id_url" readonly>
</div>
<div class="form-group">
<label class="col-md-3 control-label" for="id_url">{% trans "Voucher link" %}</label>
<div class="col-md-9">
<input type="text" name="url"
value="{% abseventurl request.event "presale:event.redeem" %}?voucher={{ voucher.code|urlencode }}{% if voucher.subevent_id %}&subevent={{ voucher.subevent_id }}{% endif %}"
class="form-control"
id="id_url" readonly>
</div>
{% endif %}
</div>
{% endif %}
{% bootstrap_field form.max_usages layout="control" %}
{% bootstrap_field form.valid_until layout="control" %}

View File

@@ -29,4 +29,9 @@ def getitem_filter(value, itemname):
if not value:
return ''
return value[itemname]
try:
return value[itemname]
except KeyError:
return ''
except TypeError:
return ''

View File

@@ -45,7 +45,8 @@ class PropagatedNode(Node):
<div class="propagated-settings-box locked panel panel-default">
<div class="panel-heading">
<input type="hidden" name="_settings_ignore" value="{fnames}">
<button class="btn btn-default pull-right btn-xs" name="decouple" value="{fnames}" data-action="unlink">
<input type="hidden" name="decouple" value="">
<button type="button" class="btn btn-default pull-right btn-xs" value="{fnames}" data-action="unlink">
<span class="fa fa-unlock"></span> {text_unlink}
</button>
<h4 class="panel-title">

View File

@@ -112,6 +112,8 @@ urlpatterns = [
re_path(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email$',
organizer.OrganizerMailSettings.as_view(), name='organizer.settings.mail'),
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email/setup$',
organizer.MailSettingsSetup.as_view(), name='organizer.settings.mail.setup'),
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email/preview$',
organizer.MailSettingsPreview.as_view(), name='organizer.settings.mail.preview'),
re_path(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
@@ -214,6 +216,7 @@ urlpatterns = [
re_path(r'^settings/tickets/preview/(?P<output>[^/]+)$', event.TicketSettingsPreview.as_view(),
name='event.settings.tickets.preview'),
re_path(r'^settings/email$', event.MailSettings.as_view(), name='event.settings.mail'),
re_path(r'^settings/email/setup$', event.MailSettingsSetup.as_view(), name='event.settings.mail.setup'),
re_path(r'^settings/email/preview$', event.MailSettingsPreview.as_view(), name='event.settings.mail.preview'),
re_path(r'^settings/email/layoutpreview$', event.MailSettingsRendererPreview.as_view(),
name='event.settings.mail.preview.layout'),

View File

@@ -65,9 +65,7 @@ from i18nfield.utils import I18nJSONEncoder
from pytz import timezone
from pretix.base.channels import get_all_sales_channels
from pretix.base.email import (
get_available_placeholders, test_custom_smtp_backend,
)
from pretix.base.email import get_available_placeholders
from pretix.base.models import Event, LogEntry, Order, TaxRule, Voucher
from pretix.base.models.event import EventMetaValue
from pretix.base.services import tickets
@@ -83,6 +81,7 @@ from pretix.control.forms.event import (
TicketSettingsForm, WidgetCodeForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views.mailsetup import MailSettingsSetupView
from pretix.control.views.user import RecentAuthenticationRequiredMixin
from pretix.helpers.database import rolledback_transaction
from pretix.multidomain.urlreverse import get_event_domain
@@ -355,19 +354,17 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
}
with transaction.atomic():
allow_restricted = request.user.has_active_staff_session(request.session.session_key)
for key, value in request.POST.items():
if key.startswith("plugin:"):
module = key.split(":")[1]
if value == "enable" and module in plugins_available:
if getattr(plugins_available[module], 'restricted', False):
if not allow_restricted:
if module not in request.event.settings.allowed_restricted_plugins:
continue
self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user,
data={'plugin': module})
self.object.enable_plugin(module, allow_restricted=allow_restricted)
self.object.enable_plugin(module, allow_restricted=request.event.settings.allowed_restricted_plugins)
else:
self.request.event.log_action('pretix.event.plugins.disabled', user=self.request.user,
data={'plugin': module})
@@ -639,29 +636,29 @@ class MailSettings(EventSettingsViewMixin, EventSettingsFormView):
k: form.cleaned_data.get(k) for k in form.changed_data
}
)
if request.POST.get('test', '0').strip() == '1':
backend = self.request.event.get_mail_backend(force_custom=True, timeout=10)
try:
test_custom_smtp_backend(backend, self.request.event.settings.mail_from)
except Exception as e:
messages.warning(self.request, _('An error occurred while contacting the SMTP server: %s') % str(e))
else:
if form.cleaned_data.get('smtp_use_custom'):
messages.success(self.request, _('Your changes have been saved and the connection attempt to '
'your SMTP server was successful.'))
else:
messages.success(self.request, _('We\'ve been able to contact the SMTP server you configured. '
'Remember to check the "use custom SMTP server" checkbox, '
'otherwise your SMTP server will not be used.'))
else:
messages.success(self.request, _('Your changes have been saved.'))
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.get(request)
class MailSettingsSetup(EventPermissionRequiredMixin, MailSettingsSetupView):
permission = 'can_change_event_settings'
basetpl = 'pretixcontrol/event/base.html'
def get_success_url(self) -> str:
return reverse('control:event.settings.mail', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
})
def log_action(self, data):
self.request.event.log_action(
'pretix.event.settings', user=self.request.user, data=data
)
class MailSettingsPreview(EventPermissionRequiredMixin, View):
permission = 'can_change_event_settings'
@@ -1416,7 +1413,7 @@ class QuickSetupView(FormView):
})
quota.items.add(*items)
self.request.event.set_active_plugins(plugins_active, allow_restricted=True)
self.request.event.set_active_plugins(plugins_active, allow_restricted=plugins_active)
self.request.event.save()
messages.success(self.request, _('Your changes have been saved. You can now go on with looking at the details '
'or take your event live to start selling!'))

View File

@@ -0,0 +1,279 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
import dns.resolver
from django.conf import settings
from django.contrib import messages
from django.core.mail import get_connection
from django.shortcuts import redirect
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from pretix.base import email
from pretix.base.models import Event
from pretix.base.services.mail import mail
from pretix.control.forms.filter import OrganizerFilterForm
from pretix.control.forms.mailsetup import SimpleMailForm, SMTPMailForm
logger = logging.getLogger(__name__)
def get_spf_record(hostname):
try:
r = dns.resolver.Resolver()
for resp in r.query(hostname, 'TXT'):
data = b''.join(resp.strings).decode()
if data.lower().strip().startswith('v=spf1 '): # RFC7208, section 4.5
return data
except:
logger.exception("Could not fetch SPF record")
def _check_spf_record(not_found_lookup_parts, spf_record, depth):
if depth > 10: # prevent infinite loops
return
parts = spf_record.lower().split(" ") # RFC 7208, section 4.6.1
for p in parts:
try:
not_found_lookup_parts.remove(p)
except KeyError:
pass
if not not_found_lookup_parts: # save some DNS requests if we already found everything
return
for p in parts:
if p.startswith('include:') or p.startswith('+include:'):
_, hostname = p.split(':')
rec_record = get_spf_record(hostname)
if rec_record:
_check_spf_record(not_found_lookup_parts, rec_record, depth + 1)
def check_spf_record(lookup, spf_record):
"""
Check that all parts of lookup appear somewhere in the given SPF record, resolving
include: directives recursively
"""
not_found_lookup_parts = set(lookup.split(" "))
_check_spf_record(not_found_lookup_parts, spf_record, 0)
return not not_found_lookup_parts
class MailSettingsSetupView(TemplateView):
template_name = 'pretixcontrol/email_setup.html'
basetpl = None
@cached_property
def object(self):
return getattr(self.request, 'event', self.request.organizer)
@cached_property
def smtp_form(self):
return SMTPMailForm(
obj=self.object,
prefix='smtp',
data=self.request.POST if (self.request.method == "POST" and self.request.POST.get("mode") == "smtp") else None,
)
@cached_property
def simple_form(self):
return SimpleMailForm(
obj=self.object,
prefix='simple',
data=self.request.POST if (self.request.method == "POST" and self.request.POST.get("mode") == "simple") else None,
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['basetpl'] = self.basetpl
ctx['object'] = self.object
ctx['smtp_form'] = self.smtp_form
ctx['simple_form'] = self.simple_form
ctx['default_sender_address'] = settings.MAIL_FROM_ORGANIZERS
if 'mode' in self.request.POST:
ctx['mode'] = self.request.POST.get('mode')
elif self.object.settings.smtp_use_custom:
ctx['mode'] = 'smtp'
elif self.object.settings.mail_from not in (settings.MAIL_FROM_ORGANIZERS, settings.MAIL_FROM):
ctx['mode'] = 'simple'
else:
ctx['mode'] = 'system'
return ctx
@cached_property
def filter_form(self):
return OrganizerFilterForm(data=self.request.GET, request=self.request)
def post(self, request, *args, **kwargs):
if request.POST.get('mode') == 'system':
if isinstance(self.object, Event) and 'mail_from' in self.object.organizer.settings._cache():
self.object.settings.mail_from = settings.MAIL_FROM_ORGANIZERS
else:
del self.object.settings.mail_from
self.object.settings.smtp_use_custom = False
del self.object.settings.smtp_host
del self.object.settings.smtp_port
del self.object.settings.smtp_username
del self.object.settings.smtp_password
del self.object.settings.smtp_use_tls
del self.object.settings.smtp_use_ssl
messages.success(request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
elif request.POST.get('mode') == 'reset':
del self.object.settings.mail_from
del self.object.settings.smtp_use_custom
del self.object.settings.smtp_host
del self.object.settings.smtp_port
del self.object.settings.smtp_username
del self.object.settings.smtp_password
del self.object.settings.smtp_use_tls
del self.object.settings.smtp_use_ssl
messages.success(request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
elif request.POST.get('mode') == 'simple':
if not self.simple_form.is_valid():
return super().get(request, *args, **kwargs)
session_key = f'sender_mail_verification_code_{self.request.path}_{self.simple_form.cleaned_data.get("mail_from")}'
allow_save = (
(not settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED or
('verification' in self.request.POST and self.request.POST.get('verification', '') == self.request.session.get(session_key, None))) and
(not settings.MAIL_CUSTOM_SENDER_SPF_STRING or self.request.POST.get('state') == 'save')
)
if allow_save:
for k, v in self.simple_form.cleaned_data.items():
self.object.settings.set(k, v)
self.log_action(self.simple_form.cleaned_data)
if session_key in request.session:
del request.session[session_key]
messages.success(request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
spf_warning = None
spf_record = None
if settings.MAIL_CUSTOM_SENDER_SPF_STRING:
hostname = self.simple_form.cleaned_data['mail_from'].split('@')[-1]
spf_record = get_spf_record(hostname)
if not spf_record:
spf_warning = _(
'We could not find an SPF record set for the domain you are trying to use. You can still '
'proceed, but it will increase the chance of emails going to spam or being rejected. We '
'strongly recommend setting an SPF record on the domain. You can do so through the DNS '
'settings at the provider you registered your domain with.'
)
elif not check_spf_record(settings.MAIL_CUSTOM_SENDER_SPF_STRING, spf_record):
spf_warning = _(
'We found an SPF record set for the domain you are trying to use, but it does not include this '
'system\'s email server. This means that there is a very high chance most of the emails will be '
'rejected or marked as spam. You should update the DNS settings of your domain to include '
'this system in the SPF record.'
)
if settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED:
if 'verification' in self.request.POST:
messages.error(request, _('The verification code was incorrect, please try again.'))
else:
self.request.session[session_key] = get_random_string(length=6, allowed_chars='1234567890')
mail(
self.simple_form.cleaned_data.get('mail_from'),
_('Sender address verification'),
'pretixcontrol/email/email_setup.txt',
{
'code': self.request.session[session_key],
'address': self.simple_form.cleaned_data.get('mail_from'),
'instance': settings.PRETIX_INSTANCE_NAME,
},
None,
locale=self.request.LANGUAGE_CODE,
user=self.request.user
)
return self.response_class(
request=self.request,
template='pretixcontrol/email_setup_simple.html',
context={
'basetpl': self.basetpl,
'object': self.object,
'verification': settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED,
'spf_warning': spf_warning,
'spf_record': spf_record,
'spf_key': settings.MAIL_CUSTOM_SENDER_SPF_STRING,
'recp': self.simple_form.cleaned_data.get('mail_from')
},
using=self.template_engine,
)
elif request.POST.get('mode') == 'smtp':
if not self.smtp_form.is_valid():
return super().get(request, *args, **kwargs)
if request.POST.get('state') == 'save':
for k, v in self.smtp_form.cleaned_data.items():
self.object.settings.set(k, v)
self.object.settings.smtp_use_custom = True
self.log_action({**self.smtp_form.cleaned_data, 'smtp_use_custom': True})
messages.success(request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
backend = get_connection(
backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host=self.smtp_form.cleaned_data['smtp_host'],
port=self.smtp_form.cleaned_data['smtp_port'],
username=self.smtp_form.cleaned_data.get('smtp_username', ''),
password=self.smtp_form.cleaned_data.get('smtp_password', ''),
use_tls=self.smtp_form.cleaned_data.get('smtp_use_tls', False),
use_ssl=self.smtp_form.cleaned_data.get('smtp_use_ssl', False),
fail_silently=False,
timeout=10,
)
try:
email.test_custom_smtp_backend(backend, self.smtp_form.cleaned_data.get('mail_from'))
except Exception as e:
messages.error(self.request, _('An error occurred while contacting the SMTP server: %s') % str(e))
return self.get(request, *args, **kwargs)
return self.response_class(
request=self.request,
template='pretixcontrol/email_setup_smtp.html',
context={
'basetpl': self.basetpl,
'object': self.object,
'known_host_problem': {
'smtp.gmail.com': _(
'We recommend not using Google Mail for transactional emails. If you try sending many '
'emails in a short amount of time, e.g. when sending information to all your ticket '
'buyers, there is a high chance Google will not deliver all of your emails since they '
'impose a maximum number of emails per time period.'
),
}.get(self.smtp_form.cleaned_data['smtp_host']),
},
using=self.template_engine,
)

View File

@@ -265,7 +265,7 @@ class EventWizard(SafeSessionWizardView):
event.has_subevents = foundation_data['has_subevents']
event.testmode = True
form_dict['basics'].save()
event.set_active_plugins(settings.PRETIX_PLUGINS_DEFAULT.split(","), allow_restricted=True)
event.set_active_plugins(settings.PRETIX_PLUGINS_DEFAULT.split(","), allow_restricted=settings.PRETIX_PLUGINS_DEFAULT.split(","))
event.save(update_fields=['plugins'])
event.log_action(
'pretix.event.added',

View File

@@ -2215,12 +2215,10 @@ class OrderGo(EventPermissionRequiredMixin, View):
return redirect('control:event.order', event=request.event.slug, organizer=request.event.organizer.slug,
code=order.code)
except Order.DoesNotExist:
try:
i = self.request.event.invoices.get(Q(invoice_no=code) | Q(full_invoice_no=code))
i = self.request.event.invoices.filter(Q(invoice_no=code) | Q(full_invoice_no=code)).first()
if i:
return redirect('control:event.order', event=request.event.slug, organizer=request.event.organizer.slug,
code=i.order.code)
except Invoice.DoesNotExist:
pass
messages.error(request, _('There is no order with the given order code.'))
return redirect('control:event.orders', event=request.event.slug, organizer=request.event.organizer.slug)
@@ -2231,7 +2229,7 @@ class ExportMixin:
def exporters(self):
exporters = []
responses = register_data_exporters.send(self.request.event)
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
if self.request.GET.get("identifier") and ex.identifier != self.request.GET.get("identifier"):
continue

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