Compare commits

..
Author SHA1 Message Date
Raphael Michel 0ce2082f00 Event dashboard: Use intcomma in numbers (Z#23175343) 2024-12-05 10:23:16 +01:00
Raphael Michel 70f06a8f40 Fix an incorrect exception handling 2024-12-04 16:59:58 +01:00
Mira Weller a747ab154a Fix cursor on elements in disabled fieldsets' legends 2024-12-04 14:54:49 +01:00
Mira 6317233150 New accordion panels using <fieldset> (#4681) 2024-12-04 14:34:42 +01:00
Richard Schreiber 4d94158ff0 Improve organizer/event-series calendar UI on mobile 2024-12-04 08:17:52 +01:00
Raphael Michel 8f92eb2d2d remove debug statement 2024-12-03 12:40:29 +01:00
Richard Schreiber f29896b267 [A11y] Fix missing aria-hidden and translation 2024-12-03 12:10:16 +01:00
Raphael MichelandMira 2dc625cf31 Add the option to introduce rich-text placeholders (#4657)
* Add the option to introduce rich-text placeholders

* Add tests in test_format

* Add some css

* Block vs inline

* Some fixed css

* Update src/pretix/control/forms/event.py

Co-authored-by: Mira <weller@rami.io>

* Add missing docstring prat

---------

Co-authored-by: Mira <weller@rami.io>
2024-12-03 11:38:15 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 855226d37c Update ua-parser requirement from ==0.18.* to ==1.0.* (#4665)
Updates the requirements on [ua-parser](https://github.com/ua-parser/uap-python) to permit the latest version.
- [Release notes](https://github.com/ua-parser/uap-python/releases)
- [Commits](https://github.com/ua-parser/uap-python/compare/0.18.0...1.0.0)

---
updated-dependencies:
- dependency-name: ua-parser
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 11:18:37 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 648c0da9fe Update webauthn requirement from ==2.2.* to ==2.3.* (#4655)
Updates the requirements on [webauthn](https://github.com/duo-labs/py_webauthn) to permit the latest version.
- [Release notes](https://github.com/duo-labs/py_webauthn/releases)
- [Changelog](https://github.com/duo-labs/py_webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/duo-labs/py_webauthn/compare/v2.2.0...v2.3.0)

---
updated-dependencies:
- dependency-name: webauthn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 11:01:05 +01:00
Raphael Michel 59e3494fa2 Add fee type for late fees (#4656) 2024-12-03 11:00:11 +01:00
Mira c4ff57c07a Change error message for unavailable addon products (#4673)
This can not only happen in case of sold-out addons, but also if they are e.g. not available via the current sales channel.
2024-12-03 10:59:15 +01:00
Raphael MichelandRichard Schreiber cc4fbfe4c7 API: Allow to block/unblock seats in bulk (#4668)
* API: Allow to block/unblock seats in bulk

* Update doc/api/resources/seats.rst

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update doc/api/resources/seats.rst

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update doc/api/resources/seats.rst

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update src/pretix/api/views/event.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-12-02 16:03:11 +01:00
Raphael MichelandMira e99ee91573 Allow to use custom domains for some but not all events (Z#23153875) (#4627)
* Allow to use custom domains for some but not all events

* Update src/pretix/multidomain/urlreverse.py

* Apply suggestions from code review

Co-authored-by: Mira <weller@rami.io>

* Logging for domain config changes

---------

Co-authored-by: Mira <weller@rami.io>
2024-12-02 15:58:50 +01:00
Patrick Chilton e2753686ee Translations: Update Hungarian
Currently translated at 10.8% (629 of 5782 strings)

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

powered by weblate
2024-12-02 15:58:41 +01:00
CVZ-es 33f8b9851e Translations: Update Spanish
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-12-02 15:58:41 +01:00
CVZ-es e3d8cf07af Translations: Update French
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-12-02 15:58:41 +01:00
Mira Weller 0279ca7d94 Add missing error handling to addressform.js 2024-12-02 10:15:16 +01:00
Richard Schreiber d1989c3cd3 Fix all-optional in address-form for resellers (#4672) 2024-12-02 09:46:33 +01:00
Raphael Michel 61cb2e15cf Fix validation crash of InvoiceNameForm 2024-11-29 20:08:36 +01:00
Mira Weller f2ee1d00b3 Don't use animation for address information load indicator 2024-11-29 17:09:14 +01:00
Mira e8e9698a31 Update address field logic (Z#23163120) (#4659)
* Move country-dependent JS logic to separate file (avoids code duplication for presale and control)
* Correctly apply "required" attribute to address state field
* Load address format information when selecting country
* Fix some other bugs and inconsistencies
2024-11-29 14:56:56 +01:00
Richard Schreiber a1bf7be244 [A11y] Improve customer account pages (#4654) 2024-11-29 14:16:40 +01:00
Patrick Chilton f4ca9a5681 Translations: Update Hungarian
Currently translated at 45.6% (106 of 232 strings)

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

powered by weblate
2024-11-29 09:22:29 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e6d984538f Update django-statici18n requirement from ==2.5.* to ==2.6.* (#4664)
Updates the requirements on [django-statici18n](https://github.com/zyegfryed/django-statici18n) to permit the latest version.
- [Changelog](https://github.com/zyegfryed/django-statici18n/blob/main/docs/changelog.rst)
- [Commits](https://github.com/zyegfryed/django-statici18n/compare/v2.5.0...v2.6.0)

---
updated-dependencies:
- dependency-name: django-statici18n
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-29 09:22:04 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9f1ee9157f Update protobuf requirement from ==5.28.* to ==5.29.* (#4666)
Updates the requirements on [protobuf](https://github.com/protocolbuffers/protobuf) to permit the latest version.
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/protobuf_release.bzl)
- [Commits](https://github.com/protocolbuffers/protobuf/compare/v5.28.0-rc1...v5.29.0)

---
updated-dependencies:
- dependency-name: protobuf
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-29 09:21:54 +01:00
Raphael Michel 242e5af4b5 Bump version to 2024.12.0.dev0 2024-11-27 13:57:03 +01:00
Raphael Michel 7d6e98e6da Bump version to 2024.11.0 2024-11-27 13:56:37 +01:00
Mira 27f964f3ae Checkout flow: Observe direction when skipping AddOnsStep (#4658) 2024-11-27 11:13:07 +01:00
Patrick Chilton 84b3060c0f Translations: Update Hungarian
Currently translated at 10.7% (623 of 5782 strings)

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

powered by weblate
2024-11-27 11:10:55 +01:00
CVZ-es 25dcb72f92 Translations: Update Spanish
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-11-27 11:10:55 +01:00
CVZ-es 4b078867c6 Translations: Update French
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-11-27 11:10:55 +01:00
Jakub Stribrny c595a59d4a Translations: Update Czech
Currently translated at 73.5% (4250 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
Patrick Chilton f164daeaee Translations: Update Hungarian
Currently translated at 10.7% (621 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
gabriblas c6b6dd8d49 Translations: Update Italian
Currently translated at 24.3% (1409 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
CVZ-es 8038c87963 Translations: Update French
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
Ryo c45a970d32 Translations: Update Japanese
Currently translated at 3.8% (220 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
kei ogane a34517233d Translations: Update Japanese
Currently translated at 3.8% (220 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
Yasunobu YesNo Kawaguchi 8fb2e5383c Translations: Update Japanese
Currently translated at 3.4% (200 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
CVZ-es 86a00f3338 Translations: Update Spanish
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
CVZ-es c8c0d3e7f5 Translations: Update French
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-11-26 18:48:58 +01:00
Raphael Michel 7dd455ce15 Fix #4641 -- Make usage of argon2id optional (#4643) 2024-11-26 17:31:27 +01:00
Richard Schreiber 391eda25da [A11y] Improve color combinations for alerts 2024-11-21 13:58:19 +01:00
Raphael Michel fcff5a522d Fix inconsistent labels 2024-11-19 16:36:09 +01:00
Raphael Michel 7e93d38a01 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-11-19 16:33:52 +01:00
Raphael Michel 6469381899 Translations: Update German
Currently translated at 100.0% (5782 of 5782 strings)

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

powered by weblate
2024-11-19 16:33:52 +01:00
Raphael Michel 761706c60c Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-11-19 16:16:36 +01:00
CVZ-es f91315c88e Translations: Update Spanish
Currently translated at 100.0% (5809 of 5809 strings)

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

powered by weblate
2024-11-19 16:16:13 +01:00
CVZ-es bc05afeab9 Translations: Update French
Currently translated at 100.0% (5779 of 5779 strings)

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

powered by weblate
2024-11-19 16:16:13 +01:00
Raphael Michel 02d495d287 Revert "Update po files"
This reverts commit 894878d9da.
2024-11-19 16:15:55 +01:00
Raphael Michel 894878d9da Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-11-19 16:15:26 +01:00
Raphael MichelandRichard Schreiber 5896ca0197 Event creation: Prevent accidentally creating events without tax rate (#4623)
* Event creation: Prevent accidentally creating events without tax rate

* Update src/pretix/control/forms/event.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Fix tests

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-11-19 16:14:56 +01:00
Raphael Michel fe6fc8df32 Fix placehodler sample in empty series (PRETIXEU-ATN) 2024-11-19 16:14:13 +01:00
dependabot[bot] 9de8f3a775 Update aiohttp requirement from ==3.10.* to ==3.11.*
Updates the requirements on [aiohttp](https://github.com/aio-libs/aiohttp) to permit the latest version.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.10.0b0...v3.11.0)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-19 15:08:00 +01:00
Martin Gross c92bb9cb8b Stripe: (FIX) Make MobilePay optional 2024-11-19 13:17:52 +01:00
Raphael Michel 76ecec8b98 Scheduled mails: Allow subevent-dependent placeholders (Z#23171818) (#4629) 2024-11-19 10:50:10 +01:00
Mira 4b8416df8f docs: update nginx config example (#4640) 2024-11-19 09:28:01 +01:00
Martin GrossandRaphael Michel a601c75923 CheckIns: Display a source_type icon (barcode/nfc) where known (#4628)
Co-authored-by: Raphael Michel <michel@rami.io>
2024-11-18 17:50:43 +01:00
Raphael Michel f94227f00f Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5779 of 5779 strings)

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

powered by weblate
2024-11-18 17:49:24 +01:00
Raphael Michel a0c1e5369c Translations: Update German
Currently translated at 100.0% (5779 of 5779 strings)

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

powered by weblate
2024-11-18 17:49:24 +01:00
Jakub Stribrny 633bfcf73a Translations: Update Czech
Currently translated at 73.5% (4253 of 5781 strings)

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

powered by weblate
2024-11-18 17:49:24 +01:00
CVZ-es 0d3b5b82c1 Translations: Update Spanish
Currently translated at 100.0% (232 of 232 strings)

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

powered by weblate
2024-11-18 17:49:24 +01:00
CVZ-es ab95f33546 Translations: Update Spanish
Currently translated at 100.0% (5784 of 5784 strings)

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

powered by weblate
2024-11-18 17:49:24 +01:00
Raphael MichelandMartin Gross 5034b366c5 Stripe: Disable SOFORT (#4616)
* Stripe: Disable SOFORT

* Update src/pretix/plugins/stripe/payment.py

Co-authored-by: Raphael Michel <michel@rami.io>

---------

Co-authored-by: Martin Gross <gross@rami.io>
2024-11-18 17:24:01 +01:00
Raphael Michel 03d3c389da Fix #1674 -- Change spelling of e-mail to email (#4636)
* Fix #1674 -- Change spelling of e-mail to email

* Conflicts and word list

* Add MobilePay to wordlist

* fix usage in tests
2024-11-18 17:21:29 +01:00
Raphael MichelandRichard Schreiber 3e934acfa0 Allow "open in new tab" for event typeahead (#4631)
* Allow "open in new tab" for event typeahead

* use default link-behaviour for e.g. open in new ...

* navigate in typeahead with tab, add esc to close

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-11-18 17:16:02 +01:00
Raphael Michel d2a364e848 Docs: Fix documentation of invoice API 2024-11-18 17:08:51 +01:00
Martin Grossandrobbi5 2824b40299 Stripe: Add MobilePay as a supported payment method (Z#23172523) (#4635)
Co-authored-by: robbi5 <richt@rami.io>
2024-11-18 15:03:32 +01:00
Damiano c6c2c90908 Translations: Update Italian
Currently translated at 24.3% (1405 of 5781 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
Pavle Ergović d4ae7df2ec Translations: Update Croatian
Currently translated at 1.7% (4 of 232 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
Pavle Ergović 79dd7fb596 Translations: Update Croatian
Currently translated at 0.3% (23 of 5781 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
CVZ-es 5ed87cd019 Translations: Update Spanish
Currently translated at 100.0% (5784 of 5784 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
CVZ-es ccdcbe0cc5 Translations: Update French
Currently translated at 100.0% (232 of 232 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
CVZ-es 4f8607a9db Translations: Update French
Currently translated at 100.0% (5781 of 5781 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
Patrick Chilton 57ecaa2676 Translations: Update Hungarian
Currently translated at 10.7% (622 of 5781 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
CVZ-es 96fd2b1a95 Translations: Update Spanish
Currently translated at 100.0% (232 of 232 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
CVZ-es 5cf24fb6a6 Translations: Update Spanish
Currently translated at 100.0% (5784 of 5784 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
Eva-Maria Obermann 1d2ea35a39 Translations: Update French
Currently translated at 100.0% (232 of 232 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
CVZ-es ac98ae7941 Translations: Update French
Currently translated at 100.0% (232 of 232 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
CVZ-es a0d055e202 Translations: Update French
Currently translated at 99.3% (5742 of 5781 strings)

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

powered by weblate
2024-11-18 10:48:18 +01:00
Raphael Michel 27ec5ca006 Change order of menu items in backend (#4633) 2024-11-18 10:45:27 +01:00
214 changed files with 27747 additions and 25472 deletions
+5
View File
@@ -288,6 +288,7 @@ Example::
[django] [django]
secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x- secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x-
debug=off debug=off
passwords_argon2=on
``secret`` ``secret``
The secret to be used by Django for signing and verification purposes. If this The secret to be used by Django for signing and verification purposes. If this
@@ -303,6 +304,10 @@ Example::
.. WARNING:: Never set this to ``True`` in production! .. WARNING:: Never set this to ``True`` in production!
``passwords_argon``
Use the ``argon2`` algorithm for password hashing. Disable on systems with a small number of CPU cores (currently
less than 8).
``profile`` ``profile``
Enable code profiling for a random subset of requests. Disabled by default, see Enable code profiling for a random subset of requests. Disabled by default, see
:ref:`perf-monitoring` for details. :ref:`perf-monitoring` for details.
+2 -3
View File
@@ -231,11 +231,10 @@ The following snippet is an example on how to configure a nginx proxy for pretix
} }
} }
server { server {
listen 443 default_server; listen 443 ssl default_server;
listen [::]:443 ipv6only=on default_server; listen [::]:443 ipv6only=on ssl default_server;
server_name pretix.mydomain.com; server_name pretix.mydomain.com;
ssl on;
ssl_certificate /path/to/cert.chain.pem; ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem; ssl_certificate_key /path/to/key.pem;
+2 -3
View File
@@ -216,11 +216,10 @@ The following snippet is an example on how to configure a nginx proxy for pretix
} }
} }
server { server {
listen 443 default_server; listen 443 ssl default_server;
listen [::]:443 ipv6only=on default_server; listen [::]:443 ipv6only=on ssl default_server;
server_name pretix.mydomain.com; server_name pretix.mydomain.com;
ssl on;
ssl_certificate /path/to/cert.chain.pem; ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem; ssl_certificate_key /path/to/key.pem;
+7 -7
View File
@@ -352,12 +352,12 @@ Fetching individual invoices
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param invoice_no: The ``invoice_no`` field of the invoice to fetch :param number: The ``number`` field of the invoice to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/download/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/download/
Download an invoice in PDF format. Download an invoice in PDF format.
@@ -384,7 +384,7 @@ Fetching individual invoices
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param invoice_no: The ``invoice_no`` field of the invoice to fetch :param number: The ``number`` field of the invoice to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
@@ -397,7 +397,7 @@ Modifying invoices
Invoices cannot be edited directly, but the following actions can be triggered: Invoices cannot be edited directly, but the following actions can be triggered:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/reissue/
Cancels the invoice and creates a new one. Cancels the invoice and creates a new one.
@@ -419,13 +419,13 @@ Invoices cannot be edited directly, but the following actions can be triggered:
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param invoice_no: The ``invoice_no`` field of the invoice to reissue :param number: The ``number`` field of the invoice to reissue
:statuscode 200: no error :statuscode 200: no error
:statuscode 400: The invoice has already been canceled :statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/regenerate/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/regenerate/
Re-generates the invoice from order data. Re-generates the invoice from order data.
@@ -447,7 +447,7 @@ Invoices cannot be edited directly, but the following actions can be triggered:
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param invoice_no: The ``invoice_no`` field of the invoice to regenerate :param number: The ``number`` field of the invoice to regenerate
:statuscode 200: no error :statuscode 200: no error
:statuscode 400: The invoice has already been canceled :statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
+112 -1
View File
@@ -249,7 +249,7 @@ Endpoints
"orderposition": null, "orderposition": null,
"cartposition": null, "cartposition": null,
"voucher": null "voucher": null
}, }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify :param event: The ``slug`` field of the event to modify
@@ -260,3 +260,114 @@ Endpoints
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource. :statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa. :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_block/
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_block/
Set the ``blocked`` attribute to ``true`` for a large number of seats at once.
You can pass either a list of ``id`` values or a list of ``seat_guid`` values.
You can pass up to 10,000 seats in one request.
The endpoint will return an error if you pass a seat ID that does not exist.
However, it will not return an error if one of the passed seats is already blocked or sold.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"ids": [12, 45, 56]
}
or
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param subevent_id: The ``id`` field of the subevent to modify
:statuscode 200: no error
:statuscode 400: The seat could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_unblock/
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_unblock/
Set the ``blocked`` attribute to ``false`` for a large number of seats at once.
You can pass either a list of ``id`` values or a list of ``seat_guid`` values.
You can pass up to 10,000 seats in one request.
The endpoint will return an error if you pass a seat ID that does not exist.
However, it will not return an error if one of the passed seat is already unblocked or is sold.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"ids": [12, 45, 56]
}
or
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param subevent_id: The ``id`` field of the subevent to modify
:statuscode 200: no error
:statuscode 400: The seat could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
+5 -5
View File
@@ -53,7 +53,7 @@ dependencies = [
"django-phonenumber-field==7.3.*", "django-phonenumber-field==7.3.*",
"django-redis==5.4.*", "django-redis==5.4.*",
"django-scopes==2.0.*", "django-scopes==2.0.*",
"django-statici18n==2.5.*", "django-statici18n==2.6.*",
"djangorestframework==3.15.*", "djangorestframework==3.15.*",
"dnspython==2.7.*", "dnspython==2.7.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
@@ -76,7 +76,7 @@ dependencies = [
"phonenumberslite==8.13.*", "phonenumberslite==8.13.*",
"Pillow==11.0.*", "Pillow==11.0.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==5.28.*", "protobuf==5.29.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==2.22", "pycparser==2.22",
@@ -97,17 +97,17 @@ dependencies = [
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2020041600", "tlds>=2020041600",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==0.18.*", "ua-parser==1.0.*",
"vat_moss_forked==2020.3.20.0.11.0", "vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==2.2.*", "webauthn==2.3.*",
"zeep==4.3.*" "zeep==4.3.*"
] ]
[project.optional-dependencies] [project.optional-dependencies]
memcached = ["pylibmc"] memcached = ["pylibmc"]
dev = [ dev = [
"aiohttp==3.10.*", "aiohttp==3.11.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.26.*", "fakeredis==2.26.*",
+1 -1
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 # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2024.11.0.dev0" __version__ = "2024.12.0.dev0"
+34
View File
@@ -989,6 +989,40 @@ def prefetch_by_id(items, qs, id_attr, target_attr):
setattr(item, target_attr, result.get(getattr(item, id_attr))) setattr(item, target_attr, result.get(getattr(item, id_attr)))
class SeatBulkBlockInputSerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.IntegerField(), required=False, allow_empty=True)
seat_guids = serializers.ListField(child=serializers.CharField(), required=False, allow_empty=True)
def to_internal_value(self, data):
data = super().to_internal_value(data)
if data.get("seat_guids") and data.get("ids"):
raise ValidationError("Please pass either seat_guids or ids.")
if data.get("seat_guids"):
seat_ids = data["seat_guids"]
if len(seat_ids) > 10000:
raise ValidationError({"seat_guids": ["Please do not pass over 10000 seats."]})
seats = {s.seat_guid: s for s in self.context["queryset"].filter(seat_guid__in=seat_ids)}
for s in seat_ids:
if s not in seats:
raise ValidationError({"seat_guids": [f"The seat '{s}' does not exist."]})
elif data.get("ids"):
seat_ids = data["ids"]
if len(seat_ids) > 10000:
raise ValidationError({"ids": ["Please do not pass over 10000 seats."]})
seats = self.context["queryset"].in_bulk(seat_ids)
for s in seat_ids:
if s not in seats:
raise ValidationError({"ids": [f"The seat '{s}' does not exist."]})
else:
raise ValidationError("Please pass either seat_guids or ids.")
return {"seats": seats.values()}
class SeatSerializer(I18nAwareModelSerializer): class SeatSerializer(I18nAwareModelSerializer):
orderposition = serializers.IntegerField(source='orderposition_id') orderposition = serializers.IntegerField(source='orderposition_id')
cartposition = serializers.IntegerField(source='cartposition_id') cartposition = serializers.IntegerField(source='cartposition_id')
+27 -4
View File
@@ -40,6 +40,7 @@ from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import serializers, views, viewsets from rest_framework import serializers, views, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError, NotFound, PermissionDenied, ValidationError,
) )
@@ -50,8 +51,9 @@ from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.pagination import TotalOrderingFilter from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.event import ( from pretix.api.serializers.event import (
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer, CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer, EventSettingsSerializer, ItemMetaPropertiesSerializer,
SubEventSerializer, TaxRuleSerializer, SeatBulkBlockInputSerializer, SeatSerializer, SubEventSerializer,
TaxRuleSerializer,
) )
from pretix.api.views import ConditionalListView from pretix.api.views import ConditionalListView
from pretix.base.models import ( from pretix.base.models import (
@@ -237,9 +239,9 @@ class EventViewSet(viewsets.ModelViewSet):
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value} disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled) changed = merge_dicts(enabled, disabled)
for module, action in changed.items(): for module, operation in changed.items():
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.plugins.' + action, 'pretix.event.plugins.' + operation,
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={'plugin': module} data={'plugin': module}
@@ -744,3 +746,24 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
data={"seats": [serializer.instance.pk]}, data={"seats": [serializer.instance.pk]},
) )
def bulk_change_blocked(self, blocked):
s = SeatBulkBlockInputSerializer(
data=self.request.data,
context={"event": self.request.event, "queryset": self.get_queryset()},
)
s.is_valid(raise_exception=True)
seats = s.validated_data["seats"]
for seat in seats:
seat.blocked = blocked
Seat.objects.bulk_update(seats, ["blocked"], batch_size=1000)
return Response({})
@action(methods=["POST"], detail=False)
def bulk_block(self, request, *args, **kwargs):
return self.bulk_change_blocked(True)
@action(methods=["POST"], detail=False)
def bulk_unblock(self, request, *args, **kwargs):
return self.bulk_change_blocked(False)
+1 -1
View File
@@ -152,7 +152,7 @@ class NativeAuthBackend(BaseAuthBackend):
to log in. to log in.
""" """
d = OrderedDict([ d = OrderedDict([
('email', forms.EmailField(label=_("E-mail"), max_length=254, ('email', forms.EmailField(label=_("Email"), max_length=254,
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))), 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)), max_length=4096)),
+7 -3
View File
@@ -35,6 +35,7 @@ from django.utils.translation import get_language, gettext_lazy as _
from pretix.base.models import Event from pretix.base.models import Event
from pretix.base.signals import register_html_mail_renderers from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.helpers.format import SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext get_available_placeholders, PlaceholderContext
@@ -68,7 +69,7 @@ def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
class BaseHTMLMailRenderer: class BaseHTMLMailRenderer:
""" """
This is the base class for all HTML e-mail renderers. This is the base class for all HTML email renderers.
""" """
def __init__(self, event: Event, organizer=None): def __init__(self, event: Event, organizer=None):
@@ -79,7 +80,7 @@ class BaseHTMLMailRenderer:
return self.identifier return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order=None, def render(self, plain_body: str, plain_signature: str, subject: str, order=None,
position=None) -> str: position=None, context=None) -> str:
""" """
This method should generate the HTML part of the email. This method should generate the HTML part of the email.
@@ -88,6 +89,7 @@ class BaseHTMLMailRenderer:
:param subject: The email subject. :param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``. :param order: The order if this email is connected to one, otherwise ``None``.
:param position: The order position if this email is connected to one, otherwise ``None``. :param position: The order position if this email is connected to one, otherwise ``None``.
:param context: Context to use to render placeholders in the plain body
:return: An HTML string :return: An HTML string
""" """
raise NotImplementedError() raise NotImplementedError()
@@ -134,8 +136,10 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def compile_markdown(self, plaintext): def compile_markdown(self, plaintext):
return markdown_compile_email(plaintext) return markdown_compile_email(plaintext)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
body_md = self.compile_markdown(plain_body) body_md = self.compile_markdown(plain_body)
if context:
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL, 'site_url': settings.SITE_URL,
+1 -1
View File
@@ -64,7 +64,7 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
_('Customer ID'), _('Customer ID'),
_('SSO provider'), _('SSO provider'),
_('External identifier'), _('External identifier'),
_('E-mail'), _('Email'),
_('Phone number'), _('Phone number'),
_('Full name'), _('Full name'),
] ]
+2 -2
View File
@@ -199,7 +199,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Invoice number'), _('Invoice number'),
_('Date'), _('Date'),
_('Order code'), _('Order code'),
_('E-mail address'), _('Email address'),
_('Invoice type'), _('Invoice type'),
_('Cancellation of'), _('Cancellation of'),
_('Language'), _('Language'),
@@ -326,7 +326,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Event start date'), _('Event start date'),
_('Date'), _('Date'),
_('Order code'), _('Order code'),
_('E-mail address'), _('Email address'),
_('Invoice type'), _('Invoice type'),
_('Cancellation of'), _('Cancellation of'),
_('Invoice sender:') + ' ' + _('Name'), _('Invoice sender:') + ' ' + _('Name'),
+2 -2
View File
@@ -284,7 +284,7 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Comment')) headers.append(_('Comment'))
headers.append(_('Follow-up date')) headers.append(_('Follow-up date'))
headers.append(_('Positions')) headers.append(_('Positions'))
headers.append(_('E-mail address verified')) headers.append(_('Email address verified'))
headers.append(_('External customer ID')) headers.append(_('External customer ID'))
headers.append(_('Payment providers')) headers.append(_('Payment providers'))
if form_data.get('include_payment_amounts'): if form_data.get('include_payment_amounts'):
@@ -655,7 +655,7 @@ class OrderListExporter(MultiSheetListExporter):
headers += [ headers += [
_('Sales channel'), _('Sales channel'),
_('Order locale'), _('Order locale'),
_('E-mail address verified'), _('Email address verified'),
_('External customer ID'), _('External customer ID'),
_('Check-in lists'), _('Check-in lists'),
_('Payment providers'), _('Payment providers'),
+1 -1
View File
@@ -254,7 +254,7 @@ class PasswordRecoverForm(forms.Form):
class PasswordForgotForm(forms.Form): class PasswordForgotForm(forms.Form):
email = forms.EmailField( email = forms.EmailField(
label=_('E-mail'), label=_('Email'),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
+21 -10
View File
@@ -54,6 +54,7 @@ from django.core.validators import (
from django.db.models import QuerySet from django.db.models import QuerySet
from django.forms import Select, widgets from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@@ -77,7 +78,7 @@ from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language, get_babel_locale, get_language_without_region, language,
) )
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import ( from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id, VATIDFinalError, VATIDTemporaryError, validate_vat_id,
) )
@@ -602,6 +603,7 @@ class BaseQuestionsForm(forms.Form):
questions = pos.item.questions_to_ask questions = pos.item.questions_to_ask
event = kwargs.pop('event') event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False) self.all_optional = kwargs.pop('all_optional', False)
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -676,7 +678,7 @@ class BaseQuestionsForm(forms.Form):
if item.ask_attendee_data and event.settings.attendee_addresses_asked: if item.ask_attendee_data and event.settings.attendee_addresses_asked:
add_fields['street'] = forms.CharField( add_fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional, required=self.attendee_addresses_required,
label=_('Address'), label=_('Address'),
widget=forms.Textarea(attrs={ widget=forms.Textarea(attrs={
'rows': 2, 'rows': 2,
@@ -686,7 +688,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.street if cartpos else orderpos.street), initial=(cartpos.street if cartpos else orderpos.street),
) )
add_fields['zipcode'] = forms.CharField( add_fields['zipcode'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional, required=False,
max_length=30, max_length=30,
label=_('ZIP code'), label=_('ZIP code'),
initial=(cartpos.zipcode if cartpos else orderpos.zipcode), initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
@@ -695,7 +697,7 @@ class BaseQuestionsForm(forms.Form):
}), }),
) )
add_fields['city'] = forms.CharField( add_fields['city'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional, required=False,
label=_('City'), label=_('City'),
max_length=255, max_length=255,
initial=(cartpos.city if cartpos else orderpos.city), initial=(cartpos.city if cartpos else orderpos.city),
@@ -707,11 +709,12 @@ class BaseQuestionsForm(forms.Form):
add_fields['country'] = CountryField( add_fields['country'] = CountryField(
countries=CachedCountries countries=CachedCountries
).formfield( ).formfield(
required=event.settings.attendee_addresses_required and not self.all_optional, required=self.attendee_addresses_required,
label=_('Country'), label=_('Country'),
initial=country, initial=country,
widget=forms.Select(attrs={ widget=forms.Select(attrs={
'autocomplete': 'country', 'autocomplete': 'country',
'data-country-information-url': reverse('js_helpers.states'),
}), }),
) )
c = [('', pgettext_lazy('address', 'Select state'))] c = [('', pgettext_lazy('address', 'Select state'))]
@@ -946,9 +949,9 @@ class BaseQuestionsForm(forms.Form):
d = super().clean() d = super().clean()
if self.address_validation: if self.address_validation:
self.cleaned_data = d = validate_address(d, True) self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required)
if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS: if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not d.get('state'): if not d.get('state'):
self.add_error('state', _('This field is required.')) self.add_error('state', _('This field is required.'))
@@ -1005,7 +1008,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'street': forms.Textarea(attrs={ 'street': forms.Textarea(attrs={
'rows': 2, 'rows': 2,
'placeholder': _('Street and Number'), 'placeholder': _('Street and Number'),
'autocomplete': 'street-address' 'autocomplete': 'street-address',
}), }),
'beneficiary': forms.Textarea(attrs={'rows': 3}), 'beneficiary': forms.Textarea(attrs={'rows': 3}),
'country': forms.Select(attrs={ 'country': forms.Select(attrs={
@@ -1021,7 +1024,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'data-display-dependency': '#id_is_business_1', 'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization', 'autocomplete': 'organization',
}), }),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}), 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'internal_reference': forms.TextInput, 'internal_reference': forms.TextInput,
} }
labels = { labels = {
@@ -1055,6 +1058,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
]) ])
self.fields['country'].choices = CachedCountries() self.fields['country'].choices = CachedCountries()
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
c = [('', pgettext_lazy('address', 'Select state'))] c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = self.prefix + '-' if self.prefix else '' fprefix = self.prefix + '-' if self.prefix else ''
@@ -1083,6 +1087,10 @@ class BaseInvoiceAddressForm(forms.ModelForm):
) )
self.fields['state'].widget.is_required = True self.fields['state'].widget.is_required = True
self.fields['street'].required = False
self.fields['zipcode'].required = False
self.fields['city'].required = False
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected. # Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data: if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data:
self.data = self.data.copy() self.data = self.data.copy()
@@ -1135,6 +1143,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
validate_address # local import to prevent impact on startup time validate_address # local import to prevent impact on startup time
data = self.cleaned_data data = self.cleaned_data
if not data.get('is_business'): if not data.get('is_business'):
data['company'] = '' data['company'] = ''
data['vat_id'] = '' data['vat_id'] = ''
@@ -1142,9 +1151,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
data['vat_id'] = '' data['vat_id'] = ''
if self.event.settings.invoice_address_required: if self.event.settings.invoice_address_required:
if data.get('is_business') and not data.get('company'): if data.get('is_business') and not data.get('company'):
raise ValidationError(_('You need to provide a company name.')) raise ValidationError({"company": _('You need to provide a company name.')})
if not data.get('is_business') and not data.get('name_parts'): if not data.get('is_business') and not data.get('name_parts'):
raise ValidationError(_('You need to provide your name.')) raise ValidationError(_('You need to provide your name.'))
if not self.all_optional and 'street' in self.fields and not data.get('street') and not data.get('zipcode') and not data.get('city'):
raise ValidationError({"street": _('This field is required.')})
if 'vat_id' in self.changed_data or not data.get('vat_id'): if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
+3 -3
View File
@@ -48,10 +48,10 @@ from pretix.control.forms import SingleLanguageWidget
class UserSettingsForm(forms.ModelForm): class UserSettingsForm(forms.ModelForm):
error_messages = { error_messages = {
'duplicate_identifier': _("There already is an account associated with this e-mail address. " 'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."), "Please choose a different one."),
'pw_current': _("Please enter your current password if you want to change your e-mail " 'pw_current': _("Please enter your current password if you want to change your email address "
"address or password."), "or password."),
'pw_current_wrong': _("The current password you entered was not correct."), 'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"), 'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."), 'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
@@ -81,7 +81,7 @@ class Command(BaseCommand):
try: try:
r = receiver(signal=periodic_task, sender=self) r = receiver(signal=periodic_task, sender=self)
except Exception as err: except Exception as err:
if isinstance(Exception, KeyboardInterrupt): if isinstance(err, KeyboardInterrupt):
raise err raise err
if settings.SENTRY_ENABLED: if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
+13
View File
@@ -37,6 +37,16 @@ class BaseMediaType:
def verbose_name(self): def verbose_name(self):
raise NotImplementedError() raise NotImplementedError()
@property
def icon(self):
"""
This can be:
- The name of a Font Awesome icon to represent this channel type.
- The name of a SVG icon file that is resolvable through the static file system. We recommend to design for a size of 18x14 pixels.
"""
return "circle"
def generate_identifier(self, organizer): def generate_identifier(self, organizer):
if self.medium_created_by_server: if self.medium_created_by_server:
raise NotImplementedError() raise NotImplementedError()
@@ -59,6 +69,7 @@ class BaseMediaType:
class BarcodePlainMediaType(BaseMediaType): class BarcodePlainMediaType(BaseMediaType):
identifier = 'barcode' identifier = 'barcode'
verbose_name = _('Barcode / QR-Code') verbose_name = _('Barcode / QR-Code')
icon = 'qrcode'
medium_created_by_server = True medium_created_by_server = True
supports_giftcard = False supports_giftcard = False
supports_orderposition = True supports_orderposition = True
@@ -75,6 +86,7 @@ class BarcodePlainMediaType(BaseMediaType):
class NfcUidMediaType(BaseMediaType): class NfcUidMediaType(BaseMediaType):
identifier = 'nfc_uid' identifier = 'nfc_uid'
verbose_name = _('NFC UID-based') verbose_name = _('NFC UID-based')
icon = 'pretixbase/img/media/nfc_uid.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = False supports_orderposition = False
@@ -114,6 +126,7 @@ class NfcUidMediaType(BaseMediaType):
class NfcMf0aesMediaType(BaseMediaType): class NfcMf0aesMediaType(BaseMediaType):
identifier = 'nfc_mf0aes' identifier = 'nfc_mf0aes'
verbose_name = 'NFC Mifare Ultralight AES' verbose_name = 'NFC Mifare Ultralight AES'
icon = 'pretixbase/img/media/nfc_secure.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = False supports_orderposition = False
+1 -1
View File
@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
('password', models.CharField(verbose_name='password', max_length=128)), ('password', models.CharField(verbose_name='password', max_length=128)),
('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)), ('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)),
('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')), ('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')),
('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='E-mail', null=True, ('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='Email', null=True,
db_index=True)), db_index=True)),
('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)), ('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)),
('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)), ('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)),
@@ -9,6 +9,7 @@ from decimal import Decimal
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import i18nfield.fields import i18nfield.fields
from argon2.exceptions import HashingError
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.db import migrations, models from django.db import migrations, models
@@ -25,7 +26,14 @@ def initial_user(apps, schema_editor):
user = User(email='admin@localhost') user = User(email='admin@localhost')
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
user.password = make_password('admin') try:
user.password = make_password('admin')
except HashingError:
raise Exception(
"Could not hash password of initial user with argon2id. If this is a system with less than 8 CPU cores, "
"you might need to disable argon2id by setting `passwords_argon2=off` in the `[django]` section of the "
"pretix.cfg configuration file."
)
user.save() user.save()
@@ -48,7 +56,7 @@ class Migration(migrations.Migration):
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='E-mail')), ('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='Email')),
('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')), ('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')),
('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')), ('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')),
('is_active', models.BooleanField(default=True, verbose_name='Is active')), ('is_active', models.BooleanField(default=True, verbose_name='Is active')),
@@ -232,7 +240,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=16, verbose_name='Order code')), ('code', models.CharField(max_length=16, verbose_name='Order code')),
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')),
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
('datetime', models.DateTimeField(verbose_name='Date')), ('datetime', models.DateTimeField(verbose_name='Date')),
@@ -187,7 +187,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=16, verbose_name='Order code')), ('code', models.CharField(max_length=16, verbose_name='Order code')),
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')),
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
('datetime', models.DateTimeField(verbose_name='Date')), ('datetime', models.DateTimeField(verbose_name='Date')),
@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
('email', models.EmailField(max_length=254, verbose_name='E-mail address')), ('email', models.EmailField(max_length=254, verbose_name='Email address')),
('locale', models.CharField(default='en', max_length=190)), ('locale', models.CharField(default='en', max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')), ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')), ('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),
@@ -35,7 +35,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
('email', models.EmailField(max_length=254, verbose_name='E-mail address')), ('email', models.EmailField(max_length=254, verbose_name='Email address')),
('locale', models.CharField(default='en', max_length=190)), ('locale', models.CharField(default='en', max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')), ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')), ('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),
@@ -163,7 +163,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)), ('action_type', models.CharField(max_length=255)),
('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)), ('method', models.CharField(choices=[('mail', 'Email')], max_length=255)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to='pretixbase.Event')), to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)), ('action_type', models.CharField(max_length=255)),
('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)), ('method', models.CharField(choices=[('mail', 'Email')], max_length=255)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('enabled', models.BooleanField(default=True)), ('enabled', models.BooleanField(default=True)),
+2 -2
View File
@@ -56,7 +56,7 @@ from pretix.base.signals import order_import_columns
class EmailColumn(ImportColumn): class EmailColumn(ImportColumn):
identifier = 'email' identifier = 'email'
verbose_name = gettext_lazy('E-mail address') verbose_name = gettext_lazy('Email address')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:
@@ -322,7 +322,7 @@ class AttendeeNamePart(ImportColumn):
class AttendeeEmail(ImportColumn): class AttendeeEmail(ImportColumn):
identifier = 'attendee_email' identifier = 'attendee_email'
verbose_name = gettext_lazy('Attendee e-mail address') verbose_name = gettext_lazy('Attendee email address')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:
+1 -1
View File
@@ -241,7 +241,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
email = models.EmailField(unique=True, db_index=True, null=True, blank=True, email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('E-mail'), max_length=190) verbose_name=_('Email'), max_length=190)
fullname = models.CharField(max_length=255, blank=True, null=True, fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name')) verbose_name=_('Full name'))
is_active = models.BooleanField(default=True, is_active = models.BooleanField(default=True,
+2 -2
View File
@@ -91,7 +91,7 @@ class Customer(LoggedModel):
), ),
], ],
) )
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190) email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('Email'), max_length=190)
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number')) phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
password = models.CharField(verbose_name=_('Password'), max_length=128) password = models.CharField(verbose_name=_('Password'), max_length=128)
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
@@ -392,7 +392,7 @@ class CustomerSSOClient(LoggedModel):
SCOPE_CHOICES = ( SCOPE_CHOICES = (
('openid', _('OpenID Connect access (required)')), ('openid', _('OpenID Connect access (required)')),
('profile', _('Profile data (name, addresses)')), ('profile', _('Profile data (name, addresses)')),
('email', _('E-mail address')), ('email', _('Email address')),
('phone', _('Phone number')), ('phone', _('Phone number')),
) )
+3
View File
@@ -823,6 +823,9 @@ class Event(EventMixin, LoggedModel):
self.save() self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if hasattr(other, 'alternative_domain_assignment'):
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
if not self.all_sales_channels: if not self.all_sales_channels:
self.limit_sales_channels.set( self.limit_sales_channels.set(
self.organizer.sales_channels.filter( self.organizer.sales_channels.filter(
+14
View File
@@ -159,10 +159,24 @@ class Membership(models.Model):
de = date_format(self.date_end, 'SHORT_DATE_FORMAT') de = date_format(self.date_end, 'SHORT_DATE_FORMAT')
return f'{self.membership_type.name}: {self.attendee_name} ({ds} {de})' return f'{self.membership_type.name}: {self.attendee_name} ({ds} {de})'
@property
def percentage_used(self):
if self.membership_type.max_usages and self.usages:
return int(self.usages / self.membership_type.max_usages * 100)
return 0
@property @property
def attendee_name(self): def attendee_name(self):
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme) return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
@property
def expired(self):
return time_machine_now() > self.date_end
@property
def not_yet_valid(self):
return time_machine_now() < self.date_start
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False): def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
if valid_from_not_chosen: if valid_from_not_chosen:
return not self.canceled and self.date_end >= time_machine_now() return not self.canceled and self.date_end >= time_machine_now()
+1 -1
View File
@@ -43,7 +43,7 @@ class NotificationSetting(models.Model):
:type enabled: bool :type enabled: bool
""" """
CHANNELS = ( CHANNELS = (
('mail', _('E-mail')), ('mail', _('Email')),
) )
user = models.ForeignKey('User', on_delete=models.CASCADE, user = models.ForeignKey('User', on_delete=models.CASCADE,
related_name='notification_settings') related_name='notification_settings')
+7 -5
View File
@@ -242,7 +242,7 @@ class Order(LockModel, LoggedModel):
) )
email = models.EmailField( email = models.EmailField(
null=True, blank=True, null=True, blank=True,
verbose_name=_('E-mail') verbose_name=_('Email')
) )
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, blank=True, null=True, blank=True,
@@ -317,7 +317,7 @@ class Order(LockModel, LoggedModel):
) )
email_known_to_work = models.BooleanField( email_known_to_work = models.BooleanField(
default=False, default=False,
verbose_name=_('E-mail address verified') verbose_name=_('Email address verified')
) )
invoice_dirty = models.BooleanField( invoice_dirty = models.BooleanField(
# Invoice needs to be re-issued when the order is paid again # Invoice needs to be re-issued when the order is paid again
@@ -2275,6 +2275,7 @@ class OrderFee(models.Model):
FEE_TYPE_SERVICE = "service" FEE_TYPE_SERVICE = "service"
FEE_TYPE_CANCELLATION = "cancellation" FEE_TYPE_CANCELLATION = "cancellation"
FEE_TYPE_INSURANCE = "insurance" FEE_TYPE_INSURANCE = "insurance"
FEE_TYPE_LATE = "late"
FEE_TYPE_OTHER = "other" FEE_TYPE_OTHER = "other"
FEE_TYPE_GIFTCARD = "giftcard" FEE_TYPE_GIFTCARD = "giftcard"
FEE_TYPES = ( FEE_TYPES = (
@@ -2283,6 +2284,7 @@ class OrderFee(models.Model):
(FEE_TYPE_SERVICE, _("Service fee")), (FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_CANCELLATION, _("Cancellation fee")), (FEE_TYPE_CANCELLATION, _("Cancellation fee")),
(FEE_TYPE_INSURANCE, _("Insurance fee")), (FEE_TYPE_INSURANCE, _("Insurance fee")),
(FEE_TYPE_LATE, _("Late fee")),
(FEE_TYPE_OTHER, _("Other fees")), (FEE_TYPE_OTHER, _("Other fees")),
(FEE_TYPE_GIFTCARD, _("Gift card")), (FEE_TYPE_GIFTCARD, _("Gift card")),
) )
@@ -3204,9 +3206,9 @@ class InvoiceAddress(models.Model):
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name')) company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_parts = models.JSONField(default=dict) name_parts = models.JSONField(default=dict)
street = models.TextField(verbose_name=_('Address'), blank=False) street = models.TextField(verbose_name=_('Address'), blank=True)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False) zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False) city = models.CharField(max_length=255, verbose_name=_('City'), blank=True)
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False) country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'), country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
countries=CachedCountries) countries=CachedCountries)
+1 -1
View File
@@ -73,7 +73,7 @@ class WaitingListEntry(LoggedModel):
blank=True, default=dict blank=True, default=dict
) )
email = models.EmailField( email = models.EmailField(
verbose_name=_("E-mail address") verbose_name=_("Email address")
) )
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, blank=True, null=True, blank=True,
-64
View File
@@ -31,8 +31,6 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import json
import logging
import re import re
import uuid import uuid
from collections import Counter, defaultdict, namedtuple from collections import Counter, defaultdict, namedtuple
@@ -82,8 +80,6 @@ from pretix.presale.signals import (
) )
from pretix.testutils.middleware import debugflags_var from pretix.testutils.middleware import debugflags_var
quota_logger = logging.getLogger("pretix_quota")
class CartError(Exception): class CartError(Exception):
def __init__(self, *args): def __init__(self, *args):
@@ -1127,11 +1123,6 @@ class CartManager:
) )
vouchers_ok = self._get_voucher_availability() vouchers_ok = self._get_voucher_availability()
quotas_ok = _get_quota_availability(self._quota_diff, self.real_now_dt) quotas_ok = _get_quota_availability(self._quota_diff, self.real_now_dt)
quota_logger.debug(json.dumps({
"op": "CartManager._perform_operations",
"quota_diff": {q.name: d for q, d in self._quota_diff.items()},
"quotas_ok": {q.name: d for q, d in quotas_ok.items()},
}))
err = None err = None
new_cart_positions = [] new_cart_positions = []
deleted_positions = set() deleted_positions = set()
@@ -1417,16 +1408,6 @@ class CartManager:
return diff return diff
def commit(self): def commit(self):
quota_logger.debug(json.dumps({
"op": "CartManager.commit start",
}))
for op in self._operations:
quota_logger.debug(json.dumps({
"op": "CartManager.commit operation",
"operation_type": str(type(op)),
"count": getattr(op, "count", 1) if isinstance(op, (self.AddOperation, self.ExtendOperation)) else None,
"repr": repr(op),
}))
self._check_presale_dates() self._check_presale_dates()
self._check_max_cart_size() self._check_max_cart_size()
self._calculate_expiry() self._calculate_expiry()
@@ -1443,16 +1424,6 @@ class CartManager:
if err: if err:
raise CartError(err) raise CartError(err)
for op in self._operations:
quota_logger.debug(json.dumps({
"op": "CartManager.commit operation done",
"operation_type": str(type(op)),
"count": getattr(op, "count", 1) if isinstance(op, (self.AddOperation, self.ExtendOperation)) else None,
"repr": repr(op),
}))
quota_logger.debug(json.dumps({
"op": "CartManager.commit done",
}))
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
@@ -1545,11 +1516,6 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
:param cart_id: Session ID of a guest :param cart_id: Session ID of a guest
:raises CartError: On any error that occurred :raises CartError: On any error that occurred
""" """
quota_logger.debug(json.dumps({
"op": "add_items_to_cart start",
"cart_id": cart_id,
"items": items,
}))
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
ia = False ia = False
if invoice_address: if invoice_address:
@@ -1574,10 +1540,6 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
quota_logger.debug(json.dumps({
"op": "add_items_to_cart end",
"cart_id": cart_id,
}))
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
@@ -1587,10 +1549,6 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
:param voucher: A voucher code :param voucher: A voucher code
:param cart_id: The cart ID of the cart to modify :param cart_id: The cart ID of the cart to modify
""" """
quota_logger.debug(json.dumps({
"op": "apply_voucher start",
"cart_id": cart_id,
}))
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
@@ -1605,10 +1563,6 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
quota_logger.debug(json.dumps({
"op": "apply_voucher end",
"cart_id": cart_id,
}))
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
@@ -1619,11 +1573,6 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
:param position: A cart position ID :param position: A cart position ID
:param cart_id: The cart ID of the cart to modify :param cart_id: The cart ID of the cart to modify
""" """
quota_logger.debug(json.dumps({
"op": "remove_cart_position start",
"cart_id": cart_id,
"position": position,
}))
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
@@ -1638,11 +1587,6 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
quota_logger.debug(json.dumps({
"op": "remove_cart_position end",
"cart_id": cart_id,
"position": position,
}))
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
@@ -1652,10 +1596,6 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
:param event: The event ID in question :param event: The event ID in question
:param cart_id: The cart ID of the cart to modify :param cart_id: The cart ID of the cart to modify
""" """
quota_logger.debug(json.dumps({
"op": "clear_cart start",
"cart_id": cart_id,
}))
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
@@ -1670,10 +1610,6 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
quota_logger.debug(json.dumps({
"op": "clear_cart end",
"cart_id": cart_id,
}))
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
+2 -15
View File
@@ -19,19 +19,18 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import json
import logging import logging
from itertools import groupby from itertools import groupby
from django.conf import settings from django.conf import settings
from django.db import DatabaseError, connection, transaction from django.db import DatabaseError, connection
from django.utils.timezone import now from django.utils.timezone import now
from pretix.base.models import Event, Membership, Quota, Seat, Voucher from pretix.base.models import Event, Membership, Quota, Seat, Voucher
from pretix.testutils.middleware import debugflags_var from pretix.testutils.middleware import debugflags_var
logger = logging.getLogger('pretix.base.locking') logger = logging.getLogger('pretix.base.locking')
quota_logger = logging.getLogger("pretix_quota")
# A lock acquisition is aborted if it takes longer than LOCK_ACQUISITION_TIMEOUT to prevent connection starvation # A lock acquisition is aborted if it takes longer than LOCK_ACQUISITION_TIMEOUT to prevent connection starvation
LOCK_ACQUISITION_TIMEOUT = 3 LOCK_ACQUISITION_TIMEOUT = 3
@@ -98,10 +97,6 @@ def lock_objects(objects, *, shared_lock_objects=None, replace_exclusive_with_sh
"You cannot create locks outside of an transaction" "You cannot create locks outside of an transaction"
) )
quota_logger.debug(json.dumps({
"op": "lock_objects",
"objects": [{"type": str(type(o)), "pk": o.pk, "str": str(o)} for o in objects],
}))
if 'postgresql' in settings.DATABASES['default']['ENGINE']: if 'postgresql' in settings.DATABASES['default']['ENGINE']:
shared_keys = set(pg_lock_key(obj) for obj in shared_lock_objects) if shared_lock_objects else set() shared_keys = set(pg_lock_key(obj) for obj in shared_lock_objects) if shared_lock_objects else set()
exclusive_keys = set(pg_lock_key(obj) for obj in objects) exclusive_keys = set(pg_lock_key(obj) for obj in objects)
@@ -125,14 +120,6 @@ def lock_objects(objects, *, shared_lock_objects=None, replace_exclusive_with_sh
else: else:
for model, instances in groupby(objects, key=lambda o: type(o)): for model, instances in groupby(objects, key=lambda o: type(o)):
model.objects.select_for_update().filter(pk__in=[o.pk for o in instances]) model.objects.select_for_update().filter(pk__in=[o.pk for o in instances])
quota_logger.debug(json.dumps({
"op": "lock_objects acquired",
"objects": [{"type": str(type(o)), "pk": o.pk, "str": str(o)} for o in objects],
}))
transaction.on_commit(lambda: quota_logger.debug(json.dumps({
"op": "lock_objects released",
"reason": "transaction.on_commit",
})))
class NoLockManager: class NoLockManager:
+11 -3
View File
@@ -76,7 +76,7 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.format import format_map from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals from pretix.presale.ical import get_private_icals
@@ -311,11 +311,17 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
try: try:
if plain_text_only: if plain_text_only:
body_html = None body_html = None
elif 'context' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position, context)
elif 'position' in inspect.signature(renderer.render).parameters: elif 'position' in inspect.signature(renderer.render).parameters:
# Backwards compatibility
warnings.warn('Email renderer called without context argument because context argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order, position) body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else: else:
# Backwards compatibility # Backwards compatibility
warnings.warn('E-mail renderer called without position argument because position argument is not ' warnings.warn('Email renderer called without position argument because position argument is not '
'supported.', 'supported.',
DeprecationWarning) DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order) body_html = renderer.render(content_plain, signature, raw_subject, order)
@@ -323,6 +329,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
logger.exception('Could not render HTML body') logger.exception('Could not render HTML body')
body_html = None body_html = None
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
send_task = mail_send_task.si( send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email), to=[email] if isinstance(email, str) else list(email),
cc=cc, cc=cc,
@@ -655,7 +663,7 @@ def render_mail(template, context):
if isinstance(template, LazyI18nString): if isinstance(template, LazyI18nString):
body = str(template) body = str(template)
if context: if context:
body = format_map(body, context) body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
else: else:
tpl = get_template(template) tpl = get_template(template)
body = tpl.render(context) body = tpl.render(context)
+2 -63
View File
@@ -203,7 +203,6 @@ error_messages = {
} }
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
quota_logger = logging.getLogger("pretix_quota")
def mark_order_paid(*args, **kwargs): def mark_order_paid(*args, **kwargs):
@@ -695,10 +694,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
[op.seat for op in sorted_positions if op.seat], [op.seat for op in sorted_positions if op.seat],
shared_lock_objects=[event] shared_lock_objects=[event]
) )
else:
quota_logger.debug(json.dumps({
"op": "_check_items lock skipped due to trust window",
}))
# Check availability # Check availability
for i, cp in enumerate(sorted_positions): for i, cp in enumerate(sorted_positions):
@@ -816,10 +811,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None) cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
) )
) )
if ignore_all_quotas:
quota_logger.debug(json.dumps({
"op": "quotas skipped due to trust window or voucher",
}))
if not ignore_all_quotas: if not ignore_all_quotas:
for quota in quotas: for quota in quotas:
@@ -836,15 +827,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
if not quota_ok: if not quota_ok:
# Sorry, can't let you keep that! # Sorry, can't let you keep that!
quota_logger.debug(json.dumps({
"op": "_check_items quota not ok",
}))
delete(cp) delete(cp)
else:
quota_logger.debug(json.dumps({
"op": "_check_items ok",
"q_avail_after": [{"id": q.pk, "str": q.name, "avail": v} for q, v in q_avail.items()]
}))
for voucher, cnt in v_usages.items(): for voucher, cnt in v_usages.items():
if 0 < cnt < voucher.min_usages_remaining: if 0 < cnt < voucher.min_usages_remaining:
@@ -1152,18 +1135,6 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
).filter( ).filter(
id__in=position_ids, event=event id__in=position_ids, event=event
) )
quota_logger.debug(json.dumps({
"op": "perform_order positions",
"positions": [
{
"item_id": p.item_id,
"variation_id": p.variation_id,
"id": p.pk,
"addon_to": p.addon_to_id,
"expires": p.expires.isoformat(),
} for p in positions
]
}))
if shown_total is not None and Decimal(shown_total) > Decimal("0.00") and event.currency == "XXX": if shown_total is not None and Decimal(shown_total) > Decimal("0.00") and event.currency == "XXX":
raise OrderError(error_messages['currency_XXX']) raise OrderError(error_messages['currency_XXX'])
@@ -2835,16 +2806,6 @@ class OrderChangeManager:
# Do nothing # Do nothing
return return
quota_logger.debug(json.dumps({
"op": "OrderChangeManager.commit start",
}))
for op in self._operations:
quota_logger.debug(json.dumps({
"op": "OrderChangeManager.commit operation",
"operation_type": str(type(op)),
"repr": repr(op),
}))
# Clear prefetched objects cache of order. We're going to modify the positions and fees and we have no guarantee # Clear prefetched objects cache of order. We're going to modify the positions and fees and we have no guarantee
# that every operation tuple points to a position/fee instance that has been fetched from the same object cache, # that every operation tuple points to a position/fee instance that has been fetched from the same object cache,
# so it's dangerous to keep the cache around. # so it's dangerous to keep the cache around.
@@ -2893,15 +2854,6 @@ class OrderChangeManager:
) )
order_changed.send(self.order.event, order=self.order) order_changed.send(self.order.event, order=self.order)
for op in self._operations:
quota_logger.debug(json.dumps({
"op": "OrderChangeManager.commit operation done",
"operation_type": str(type(op)),
"repr": repr(op),
}))
quota_logger.debug(json.dumps({
"op": "OrderChangeManager.commit end",
}))
def _clear_tickets_cache(self): def _clear_tickets_cache(self):
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk, tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
@@ -2925,18 +2877,11 @@ def perform_order(self, event: Event, payments: List[dict], positions: List[str]
email: str=None, locale: str=None, address: int=None, meta_info: dict=None, email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None, sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None,
api_meta: dict=None): api_meta: dict=None):
quota_logger.debug(json.dumps({
"op": "perform_order start",
}))
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
try: try:
r = _perform_order(event, payments, positions, email, locale, address, meta_info, return _perform_order(event, payments, positions, email, locale, address, meta_info,
sales_channel, shown_total, customer, api_meta) sales_channel, shown_total, customer, api_meta)
quota_logger.debug(json.dumps({
"op": "perform_order end",
}))
return r
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
@@ -3067,9 +3012,6 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False,
email_comment=None, refund_comment=None, cancel_invoice=True): email_comment=None, refund_comment=None, cancel_invoice=True):
quota_logger.debug(json.dumps({
"op": "cancel_order start",
}))
try: try:
try: try:
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application, ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
@@ -3077,9 +3019,6 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
if try_auto_refund: if try_auto_refund:
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard, _try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
comment=refund_comment) comment=refund_comment)
quota_logger.debug(json.dumps({
"op": "cancel_order end",
}))
return ret return ret
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()
+174 -5
View File
@@ -26,6 +26,7 @@ from decimal import Decimal
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -39,7 +40,8 @@ from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import ( from pretix.base.signals import (
register_mail_placeholders, register_text_placeholders, register_mail_placeholders, register_text_placeholders,
) )
from pretix.helpers.format import SafeFormatter from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.helpers.format import PlainHtmlAlternativeString, SafeFormatter
logger = logging.getLogger('pretix.base.services.placeholders') logger = logging.getLogger('pretix.base.services.placeholders')
@@ -107,6 +109,91 @@ class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder):
return self._sample return self._sample
class BaseRichTextPlaceholder(BaseTextPlaceholder):
"""
This is the base class for all placeholders which can render either to plain text
or to a rich HTML element.
"""
def __init__(self, identifier, args):
self._identifier = identifier
self._args = args
@property
def identifier(self):
return self._identifier
@property
def required_context(self):
return self._args
@property
def is_block(self):
return False
def render(self, context):
return PlainHtmlAlternativeString(
self.render_plain(**{k: context[k] for k in self._args}),
self.render_html(**{k: context[k] for k in self._args}),
self.is_block,
)
def render_html(self, **kwargs):
"""
HTML rendering of the placeholder. Should return "safe" HTML, i.e. everything needs to be
escaped.
"""
raise NotImplementedError
def render_plain(self, **kwargs):
"""
Plain text rendering of the placeholder.
"""
raise NotImplementedError
def render_sample(self, event):
return PlainHtmlAlternativeString(
self.render_sample_plain(event=event),
self.render_sample_html(event=event),
self.is_block,
)
def render_sample_html(self, event):
raise NotImplementedError
def render_sample_plain(self, event):
raise NotImplementedError
class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
def __init__(self, identifier, args, url_func, text_func, sample_url_func, sample_text_func):
super().__init__(identifier, args)
self._url_func = url_func
self._text_func = text_func
self._sample_url_func = sample_url_func
self._sample_text_func = sample_text_func
def render_html(self, **context):
text = self._text_func(**{k: context[k] for k in self._args})
url = self._url_func(**{k: context[k] for k in self._args})
return f'<a href="{url}" class="button">{escape(text)}</a>'
def render_plain(self, **context):
text = self._text_func(**{k: context[k] for k in self._args})
url = self._url_func(**{k: context[k] for k in self._args})
return f'{text}: {url}'
def render_sample_html(self, event):
text = self._sample_text_func(event)
url = self._sample_url_func(event)
return f'<a href="{url}" class="button">{escape(text)}</a>'
def render_sample_plain(self, event):
text = self._sample_text_func(event)
url = self._sample_url_func(event)
return f'{text}: {url}'
class PlaceholderContext(SafeFormatter): class PlaceholderContext(SafeFormatter):
""" """
Holds the contextual arguments and corresponding list of available placeholders for formatting Holds the contextual arguments and corresponding list of available placeholders for formatting
@@ -209,13 +296,24 @@ def get_best_name(position_or_address, parts=False):
def base_placeholders(sender, **kwargs): def base_placeholders(sender, **kwargs):
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
def _event_sample(event):
if event.has_subevents:
se = event.subevents.first()
if se:
return se.name
return event.name
ph = [ ph = [
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name 'event', ['event'], lambda event: event.name, lambda event: event.name
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name, 'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
lambda event_or_subevent: event_or_subevent.name _event_sample,
),
SimpleFunctionalTextPlaceholder(
'event_series_name', ['event', 'event_or_subevent'], lambda event, event_or_subevent: event.name,
lambda event: event.name
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug 'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
@@ -273,6 +371,27 @@ def base_placeholders(sender, **kwargs):
} }
), ),
), ),
SimpleButtonPlaceholder(
'url_button', ['order', 'event'],
url_func=lambda order, event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_secret()
}
),
text_func=lambda order, event: _("View order details"),
sample_url_func=lambda event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'hash': '98kusd8ofsj8dnkd'
}
),
sample_text_func=lambda event: _("View order details"),
),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri( 'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event, event,
@@ -337,6 +456,27 @@ def base_placeholders(sender, **kwargs):
} }
), ),
), ),
SimpleButtonPlaceholder(
'url_button', ['event', 'position'],
url_func=lambda event, position: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
),
text_func=lambda event, position: _("View registration details"),
sample_url_func=lambda event: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
sample_text_func=lambda event: _("View registration details"),
),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri( 'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event, event,
@@ -592,8 +732,8 @@ def base_placeholders(sender, **kwargs):
class FormPlaceholderMixin: class FormPlaceholderMixin:
def _set_field_placeholders(self, fn, base_parameters): def _set_field_placeholders(self, fn, base_parameters, rich=False):
placeholders = get_available_placeholders(self.event, base_parameters) placeholders = get_available_placeholders(self.event, base_parameters, rich=rich)
ht = format_placeholders_help_text(placeholders, self.event) ht = format_placeholders_help_text(placeholders, self.event)
if self.fields[fn].help_text: if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht) self.fields[fn].help_text += ' ' + str(ht)
@@ -604,7 +744,7 @@ class FormPlaceholderMixin:
) )
def get_available_placeholders(event, base_parameters): def get_available_placeholders(event, base_parameters, rich=False):
if 'order' in base_parameters: if 'order' in base_parameters:
base_parameters.append('invoice_address') base_parameters.append('invoice_address')
base_parameters.append('position_or_address') base_parameters.append('position_or_address')
@@ -613,6 +753,35 @@ def get_available_placeholders(event, base_parameters):
if not isinstance(val, (list, tuple)): if not isinstance(val, (list, tuple)):
val = [val] val = [val]
for v in val: for v in val:
if isinstance(v, BaseRichTextPlaceholder) and not rich:
continue
if all(rp in base_parameters for rp in v.required_context): if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v params[v.identifier] = v
return params return params
def get_sample_context(event, context_parameters, rich=True):
context_dict = {}
lbl = _('This value will be replaced based on dynamic parameters.')
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
sample = v.render_sample(event)
if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString(
sample.plain,
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
el='div' if sample.is_block else 'span',
title=lbl,
html=sample.html,
)
)
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
context_dict[k] = '<div class="placeholder" title="{}">{}</div>'.format(
lbl,
markdown_compile_email(str(sample))
)
else:
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
lbl,
escape(sample)
)
return context_dict
-13
View File
@@ -19,8 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import json
import logging
import sys import sys
import time import time
from collections import Counter, defaultdict from collections import Counter, defaultdict
@@ -42,8 +40,6 @@ from pretix.base.models import (
from ..signals import quota_availability from ..signals import quota_availability
quota_logger = logging.getLogger("pretix_quota")
class QuotaAvailability: class QuotaAvailability:
""" """
@@ -150,10 +146,6 @@ class QuotaAvailability:
self.results[q] = int(data[0]), None self.results[q] = int(data[0]), None
else: else:
self.results[q] = int(data[0]), int(data[1]) self.results[q] = int(data[0]), int(data[1])
quota_logger.debug(json.dumps({
"op": "quota_from_cache",
"objects": [{"pk": q.pk, "str": str(q), "result": r} for q, r in self.results.items()],
}))
if not quota_ids_set: if not quota_ids_set:
return return
@@ -169,11 +161,6 @@ class QuotaAvailability:
count_waitinglist=self.count_waitinglist): count_waitinglist=self.count_waitinglist):
self.results[q] = resp self.results[q] = resp
quota_logger.debug(json.dumps({
"op": "quota_results",
"objects": [{"pk": q.pk, "str": str(q), "result": r} for q, r in self.results.items()],
}))
self._close(quotas) self._close(quotas)
self._write_cache(quotas, now_dt) self._write_cache(quotas, now_dt)
+2 -2
View File
@@ -287,9 +287,9 @@ class PhoneNumberShredder(BaseDataShredder):
class EmailAddressShredder(BaseDataShredder): class EmailAddressShredder(BaseDataShredder):
verbose_name = _('E-mails') verbose_name = _('Emails')
identifier = 'order_emails' identifier = 'order_emails'
description = _('This will remove all e-mail addresses from orders and attendees, as well as logged email ' description = _('This will remove all email addresses from orders and attendees, as well as logged email '
'contents. This will also remove the association to customer accounts.') 'contents. This will also remove the association to customer accounts.')
def generate_files(self) -> List[Tuple[str, str, str]]: def generate_files(self) -> List[Tuple[str, str, str]]:
@@ -131,6 +131,9 @@
text-align: left; text-align: left;
padding: 0; padding: 0;
} }
.content table td.align-right {
text-align: right;
}
a.button { a.button {
display: inline-block; display: inline-block;
@@ -178,6 +181,9 @@
pre, pre code { pre, pre code {
white-space: pre-line; white-space: pre-line;
} }
.text-right, .content table td.text-right {
text-align: right;
}
{% if rtl %} {% if rtl %}
body { body {
@@ -186,6 +192,9 @@
.content { .content {
text-align: right; text-align: right;
} }
.text-right, .content table td.text-right {
text-align: left;
}
{% endif %} {% endif %}
{% block addcss %}{% endblock %} {% block addcss %}{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import template
from django.utils.html import format_html
register = template.Library()
@register.simple_tag
def icon(key, *args, **kwargs):
return format_html(
'<span class="fa fa-{} {}" aria-hidden="true"></span>',
key,
kwargs["class"] if "class" in kwargs else "",
)
+4 -4
View File
@@ -52,12 +52,12 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
# would make the numbers incorrect. If this branch executes, it's likely a bug in # would make the numbers incorrect. If this branch executes, it's likely a bug in
# pretix, but we won't show wrong numbers! # pretix, but we won't show wrong numbers!
if hide_currency: if hide_currency:
return floatformat(value, 2) return floatformat(value, "2g")
else: else:
return '{} {}'.format(arg, floatformat(value, 2)) return '{} {}'.format(arg, floatformat(value, "2g"))
if hide_currency: if hide_currency:
return floatformat(value, places) return floatformat(value, f"{places}g")
locale_parts = translation.get_language().split("-", 1) locale_parts = translation.get_language().split("-", 1)
locale = locale_parts[0] locale = locale_parts[0]
@@ -70,7 +70,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
try: try:
return format_currency(value, arg, locale=locale) return format_currency(value, arg, locale=locale)
except: except:
return '{} {}'.format(arg, floatformat(value, places)) return '{} {}'.format(arg, floatformat(value, f"{places}g"))
@register.filter("money_numberfield") @register.filter("money_numberfield")
@@ -305,6 +305,7 @@ def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes
source, source,
extensions=[ extensions=[
'markdown.extensions.sane_lists', 'markdown.extensions.sane_lists',
'markdown.extensions.tables',
EmailNl2BrExtension(), EmailNl2BrExtension(),
LinkifyAndCleanExtension( LinkifyAndCleanExtension(
linker, linker,
@@ -0,0 +1,42 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import template
from django.utils.html import format_html, mark_safe
register = template.Library()
@register.simple_tag
def textbubble(type, *args, **kwargs):
return format_html(
'<span class="textbubble-{}">{}',
type or "info",
"" if "icon" not in kwargs else format_html(
'<i class="fa fa-{}" aria-hidden="true"></i> ',
kwargs["icon"]
)
)
@register.simple_tag
def endtextbubble():
return mark_safe('</span>')
+19 -5
View File
@@ -22,16 +22,30 @@
import pycountry import pycountry
from django.http import JsonResponse from django.http import JsonResponse
from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
)
from pretix.base.models.tax import VAT_ID_COUNTRIES
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
def states(request): def states(request):
cc = request.GET.get("country", "DE") cc = request.GET.get("country", "DE")
info = {
'street': {'required': True},
'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
'state': {'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS, 'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS},
'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False},
}
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return JsonResponse({'data': []}) return JsonResponse({'data': [], **info, })
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc] types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return JsonResponse({'data': [ return JsonResponse({
{'name': s.name, 'code': s.code[3:]} 'data': [
for s in sorted(statelist, key=lambda s: s.name) {'name': s.name, 'code': s.code[3:]}
]}) for s in sorted(statelist, key=lambda s: s.name)
],
**info,
})
+52 -39
View File
@@ -35,7 +35,7 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from decimal import Decimal from decimal import Decimal
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pycountry import pycountry
@@ -76,8 +76,10 @@ from pretix.control.forms import (
) )
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
from pretix.multidomain.models import KnownDomain from pretix.multidomain.models import AlternativeDomainAssignment, KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import (
build_absolute_uri, get_organizer_domain,
)
from pretix.plugins.banktransfer.payment import BankTransfer from pretix.plugins.banktransfer.payment import BankTransfer
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -136,6 +138,11 @@ class EventWizardBasicsForm(I18nModelForm):
choices=settings.LANGUAGES, choices=settings.LANGUAGES,
label=_("Default language"), label=_("Default language"),
) )
no_taxes = forms.BooleanField(
label=_("I don't want to specify taxes now"),
help_text=_("You can always configure tax rates later."),
required=False,
)
tax_rate = forms.DecimalField( tax_rate = forms.DecimalField(
label=_("Sales tax rate"), label=_("Sales tax rate"),
help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate " help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate "
@@ -223,6 +230,11 @@ class EventWizardBasicsForm(I18nModelForm):
raise ValidationError({ raise ValidationError({
'timezone': _('Your default locale must be specified.') 'timezone': _('Your default locale must be specified.')
}) })
if not data.get("no_taxes") and not data.get("tax_rate"):
raise ValidationError({
'tax_rate': _('You have not specified a tax rate. If you do not want us to compute sales taxes, please '
'check "{field}" above.').format(field=self.fields["no_taxes"].label)
})
# change timezone # change timezone
zone = ZoneInfo(data.get('timezone')) zone = ZoneInfo(data.get('timezone'))
@@ -353,14 +365,9 @@ class EventUpdateForm(I18nModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.change_slug = kwargs.pop('change_slug', False) self.change_slug = kwargs.pop('change_slug', False)
self.domain = kwargs.pop('domain', False)
kwargs.setdefault('initial', {}) kwargs.setdefault('initial', {})
self.instance = kwargs['instance'] self.instance = kwargs['instance']
if self.domain and self.instance:
initial_domain = self.instance.domains.first()
if initial_domain:
kwargs['initial'].setdefault('domain', initial_domain.domainname)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not self.change_slug: if not self.change_slug:
@@ -369,48 +376,54 @@ class EventUpdateForm(I18nModelForm):
self.fields['location'].widget.attrs['placeholder'] = _( self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center\nHeidelberg, Germany' 'Sample Conference Center\nHeidelberg, Germany'
) )
if self.domain:
try:
self.fields['domain'] = forms.CharField( self.fields['domain'] = forms.CharField(
max_length=255, max_length=255,
label=_('Custom domain'), label=_('Domain'),
initial=self.instance.domain.domainname,
required=False,
disabled=True,
help_text=_('You can configure this in your organizer settings.')
)
except KnownDomain.DoesNotExist:
domain = get_organizer_domain(self.instance.organizer)
try:
current_domain_assignment = self.instance.alternative_domain_assignment
except AlternativeDomainAssignment.DoesNotExist:
current_domain_assignment = None
self.fields['domain'] = forms.ChoiceField(
label=_('Domain'),
help_text=_('You can add more domains in your organizer account.'),
choices=[('', _('Same as organizer account') + (f" ({domain})" if domain else ""))] + [
(d.domainname, d.domainname) for d in self.instance.organizer.domains.filter(mode=KnownDomain.MODE_ORG_ALT_DOMAIN)
],
initial=current_domain_assignment.domain_id if current_domain_assignment else "",
required=False, required=False,
help_text=_('You need to configure the custom domain in the webserver beforehand.')
) )
self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all() self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all()
self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={ self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
'data-inverse-dependency': '<[name$=all_sales_channels]', 'data-inverse-dependency': '<[name$=all_sales_channels]',
}, choices=self.fields['limit_sales_channels'].widget.choices) }, choices=self.fields['limit_sales_channels'].widget.choices)
def clean_domain(self):
d = self.cleaned_data['domain']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(event=self.instance.pk).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit) instance = super().save(commit)
if self.domain: try:
current_domain = instance.domains.first() current_domain_assignment = instance.alternative_domain_assignment
if self.cleaned_data['domain']: except AlternativeDomainAssignment.DoesNotExist:
if current_domain and current_domain.domainname != self.cleaned_data['domain']: current_domain_assignment = None
current_domain.delete() if self.cleaned_data['domain'] and not hasattr(instance, 'domain'):
KnownDomain.objects.create( domain = self.instance.organizer.domains.get(mode=KnownDomain.MODE_ORG_ALT_DOMAIN, domainname=self.cleaned_data["domain"])
organizer=instance.organizer, event=instance, domainname=self.cleaned_data['domain'] AlternativeDomainAssignment.objects.update_or_create(
) event=instance,
elif not current_domain: defaults={
KnownDomain.objects.create( "domain": domain,
organizer=instance.organizer, event=instance, domainname=self.cleaned_data['domain'] }
) )
elif current_domain: instance.cache.clear()
current_domain.delete() elif current_domain_assignment:
current_domain_assignment.delete()
instance.cache.clear() instance.cache.clear()
return instance return instance
@@ -1372,7 +1385,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self.event.meta_values_cached = self.event.meta_values.select_related('property').all() self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
for k, v in self.base_context.items(): for k, v in self.base_context.items():
self._set_field_placeholders(k, v) self._set_field_placeholders(k, v, rich=k.startswith('mail_text_'))
for k, v in list(self.fields.items()): for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked: if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
+3 -3
View File
@@ -549,7 +549,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
) )
email = forms.CharField( email = forms.CharField(
required=False, required=False,
label=_('E-mail address') label=_('Email address')
) )
comment = forms.CharField( comment = forms.CharField(
required=False, required=False,
@@ -563,7 +563,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
email_known_to_work = forms.NullBooleanField( email_known_to_work = forms.NullBooleanField(
required=False, required=False,
widget=FilterNullBooleanSelect, widget=FilterNullBooleanSelect,
label=_('E-mail address verified'), label=_('Email address verified'),
) )
total = forms.DecimalField( total = forms.DecimalField(
localize=True, localize=True,
@@ -648,7 +648,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
) )
self.fields['attendee_email'] = forms.CharField( self.fields['attendee_email'] = forms.CharField(
required=False, required=False,
label=_('Attendee e-mail address') label=_('Attendee email address')
) )
self.fields['attendee_address_company'] = forms.CharField( self.fields['attendee_address_company'] = forms.CharField(
required=False, required=False,
+1 -1
View File
@@ -128,7 +128,7 @@ class UpdateSettingsForm(SettingsForm):
) )
update_check_email = forms.EmailField( update_check_email = forms.EmailField(
required=False, required=False,
label=_("E-mail notifications"), label=_("Email notifications"),
help_text=_("We will notify you at this address if we detect that a new update is available. This " help_text=_("We will notify you at this address if we detect that a new update is available. This "
"address will not be transmitted to pretix.eu, the emails will be sent by this server " "address will not be transmitted to pretix.eu, the emails will be sent by this server "
"locally.") "locally.")
+87 -42
View File
@@ -133,63 +133,108 @@ class OrganizerDeleteForm(forms.Form):
class OrganizerUpdateForm(OrganizerForm): class OrganizerUpdateForm(OrganizerForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.domain = kwargs.pop('domain', False)
self.change_slug = kwargs.pop('change_slug', False) self.change_slug = kwargs.pop('change_slug', False)
kwargs.setdefault('initial', {}) kwargs.setdefault('initial', {})
self.instance = kwargs['instance'] self.instance = kwargs['instance']
if self.domain and self.instance:
initial_domain = self.instance.domains.filter(event__isnull=True).first()
if initial_domain:
kwargs['initial'].setdefault('domain', initial_domain.domainname)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not self.change_slug: if not self.change_slug:
self.fields['slug'].widget.attrs['readonly'] = 'readonly' self.fields['slug'].widget.attrs['readonly'] = 'readonly'
if self.domain:
self.fields['domain'] = forms.CharField(
max_length=255,
label=_('Custom domain'),
required=False,
help_text=_('You need to configure the custom domain in the webserver beforehand.')
)
def clean_domain(self):
d = self.cleaned_data['domain']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.pk,
event__isnull=True).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def clean_slug(self): def clean_slug(self):
if self.change_slug: if self.change_slug:
return self.cleaned_data['slug'] return self.cleaned_data['slug']
return self.instance.slug return self.instance.slug
def save(self, commit=True):
instance = super().save(commit)
if self.domain: class KnownDomainForm(forms.ModelForm):
current_domain = instance.domains.filter(event__isnull=True).first() class Meta:
if self.cleaned_data['domain']: model = KnownDomain
if current_domain and current_domain.domainname != self.cleaned_data['domain']: fields = ["domainname", "mode", "event"]
current_domain.delete() field_classes = {
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain']) "event": SafeModelChoiceField,
elif not current_domain: }
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
elif current_domain:
current_domain.delete()
instance.cache.clear()
for ev in instance.events.all():
ev.cache.clear()
return instance def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields["event"].queryset = self.organizer.events.all()
if self.instance and self.instance.pk:
self.fields["domainname"].widget.attrs['readonly'] = 'readonly'
def clean_domainname(self):
if self.instance and self.instance.pk:
return self.instance.domainname
d = self.cleaned_data['domainname']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.organizer).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def clean(self):
d = super().clean()
if d["mode"] == KnownDomain.MODE_ORG_DOMAIN and d["event"]:
raise ValidationError(
_("Do not choose an event for this mode.")
)
if d["mode"] == KnownDomain.MODE_ORG_ALT_DOMAIN and d["event"]:
raise ValidationError(
_("Do not choose an event for this mode. You can assign events to this domain in event settings.")
)
if d["mode"] == KnownDomain.MODE_EVENT_DOMAIN and not d["event"]:
raise ValidationError(
_("You need to choose an event.")
)
return d
class BaseKnownDomainFormSet(forms.BaseInlineFormSet):
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['organizer'] = self.organizer
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
organizer=self.organizer,
)
self.add_fields(form, None)
return form
def clean(self):
super().clean()
data = [f.cleaned_data for f in self.forms]
if len([d for d in data if d.get("mode") == KnownDomain.MODE_ORG_DOMAIN and not d.get("DELETE")]) > 1:
raise ValidationError(_("You may set only one organizer domain."))
return data
KnownDomainFormset = inlineformset_factory(
Organizer, KnownDomain,
KnownDomainForm,
formset=BaseKnownDomainFormSet,
can_order=False, can_delete=True, extra=0
)
class SafeOrderPositionChoiceField(forms.ModelChoiceField): class SafeOrderPositionChoiceField(forms.ModelChoiceField):
+1 -1
View File
@@ -40,7 +40,7 @@ class StaffSessionForm(forms.ModelForm):
class UserEditForm(forms.ModelForm): class UserEditForm(forms.ModelForm):
error_messages = { error_messages = {
'duplicate_identifier': _("There already is an account associated with this e-mail address. " 'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."), "Please choose a different one."),
'pw_mismatch': _("Please enter the same password twice"), 'pw_mismatch': _("Please enter the same password twice"),
} }
+14 -12
View File
@@ -78,7 +78,7 @@ def get_event_navigation(request: HttpRequest):
'active': url.url_name == 'event.settings.tickets', 'active': url.url_name == 'event.settings.tickets',
}, },
{ {
'label': _('E-mail'), 'label': _('Email'),
'url': reverse('control:event.settings.mail', kwargs={ 'url': reverse('control:event.settings.mail', kwargs={
'event': request.event.slug, 'event': request.event.slug,
'organizer': request.event.organizer.slug, 'organizer': request.event.organizer.slug,
@@ -132,16 +132,6 @@ def get_event_navigation(request: HttpRequest):
'icon': 'wrench', 'icon': 'wrench',
'children': event_settings 'children': event_settings
}) })
if request.event.has_subevents:
nav.append({
'label': pgettext_lazy('subevent', 'Dates'),
'url': reverse('control:event.subevents', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': ('event.subevent' in url.url_name),
'icon': 'calendar',
})
if 'can_change_items' in request.eventpermset: if 'can_change_items' in request.eventpermset:
nav.append({ nav.append({
@@ -197,6 +187,18 @@ def get_event_navigation(request: HttpRequest):
] ]
}) })
if 'can_change_event_settings' in request.eventpermset:
if request.event.has_subevents:
nav.append({
'label': pgettext_lazy('subevent', 'Dates'),
'url': reverse('control:event.subevents', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': ('event.subevent' in url.url_name),
'icon': 'calendar',
})
if 'can_view_orders' in request.eventpermset: if 'can_view_orders' in request.eventpermset:
children = [ children = [
{ {
@@ -496,7 +498,7 @@ def get_organizer_navigation(request):
'active': url.url_name.startswith('organizer.propert'), 'active': url.url_name.startswith('organizer.propert'),
}, },
{ {
'label': _('E-mail'), 'label': _('Email'),
'url': reverse('control:organizer.settings.mail', kwargs={ 'url': reverse('control:organizer.settings.mail', kwargs={
'organizer': request.organizer.slug, 'organizer': request.organizer.slug,
}), }),
@@ -61,6 +61,7 @@
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script> <script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
<script type="text/javascript" src="{% static "lightbox/js/lightbox.js" %}"></script> <script type="text/javascript" src="{% static "lightbox/js/lightbox.js" %}"></script>
<script type="text/javascript" src="{% static "are-you-sure/jquery.are-you-sure.js" %}"></script> <script type="text/javascript" src="{% static "are-you-sure/jquery.are-you-sure.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/addressform.js" %}"></script>
{% endcompress %} {% endcompress %}
{{ html_head|safe }} {{ html_head|safe }}
@@ -127,6 +127,7 @@
<strong> <strong>
<a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=c.position.order.code %}">{{ c.position.order.code }}</a>-{{ c.position.positionid }} <a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=c.position.order.code %}">{{ c.position.order.code }}</a>-{{ c.position.positionid }}
</strong> </strong>
{% include "pretixcontrol/checkin/fragment_checkin_source_type.html" with source_type=c.raw_source_type %}
{% if c.position.attendee_name %} {% if c.position.attendee_name %}
<br> <br>
<small> <small>
@@ -143,7 +144,7 @@
</small> </small>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="fa fa-qrcode fa-fw"></span> {% include "pretixcontrol/checkin/fragment_checkin_source_type.html" with source_type=c.raw_source_type %}
<span title="{{ c.raw_barcode }}"> <span title="{{ c.raw_barcode }}">
{{ c.raw_barcode|slice:":16" }}{% if c.raw_barcode|length > 16 %}…{% endif %} {{ c.raw_barcode|slice:":16" }}{% if c.raw_barcode|length > 16 %}…{% endif %}
<button type="button" class="btn btn-xs btn-link btn-clipboard" data-clipboard-text="{{ c.raw_barcode }}"> <button type="button" class="btn btn-xs btn-link btn-clipboard" data-clipboard-text="{{ c.raw_barcode }}">
@@ -0,0 +1,15 @@
{% load i18n %}
{% load static %}
{% load getitem %}
{% if source_type %}
{% with media_types|getitem:source_type as media_type %}
{% if "." in media_type.icon %}
<img src="{% static media_type.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ media_type.verbose_name }}">
{% else %}
<span class="fa fa-fw fa-{{ media_type.icon }} text-muted"
data-toggle="tooltip" title="{{ media_type.verbose_name }}"></span>
{% endif %}
{% endwith %}
{% endif %}
@@ -185,6 +185,7 @@
<span class="fa fa-magic text-muted" <span class="fa fa-magic text-muted"
data-toggle="tooltip" title="{% trans "Checked in automatically" %}"></span> data-toggle="tooltip" title="{% trans "Checked in automatically" %}"></span>
{% endif %} {% endif %}
{% include "pretixcontrol/checkin/fragment_checkin_source_type.html" with source_type=e.last_entry_source_type %}
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>
@@ -5,7 +5,7 @@
{% load static %} {% load static %}
{% block title %}{% trans "Organizer" %}{% endblock %} {% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "E-mail sending" %}</h1> <h1>{% trans "Email sending" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<div class="panel-group" id="email"> <div class="panel-group" id="email">
@@ -27,7 +27,7 @@
<div class="panel-body form-horizontal"> <div class="panel-body form-horizontal">
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
E-mails will be sent through the system's default server. They will show the following Emails will be sent through the system's default server. They will show the following
sender information: sender information:
{% endblocktrans %} {% endblocktrans %}
</p> </p>
@@ -62,7 +62,7 @@
<div class="panel-body form-horizontal"> <div class="panel-body form-horizontal">
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
E-mails will be sent through the system's default server but with your own sender Emails will be sent through the system's default server but with your own sender
address. address.
This will make your emails look more personalized and coming directly from you, but it 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. also might require some extra steps to ensure good deliverability.
@@ -5,7 +5,7 @@
{% load static %} {% load static %}
{% block title %}{% trans "Organizer" %}{% endblock %} {% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "E-mail sending" %}</h1> <h1>{% trans "Email sending" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
{% for k, v in request.POST.items %} {% for k, v in request.POST.items %}
@@ -5,7 +5,7 @@
{% load static %} {% load static %}
{% block title %}{% trans "Organizer" %}{% endblock %} {% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "E-mail sending" %}</h1> <h1>{% trans "Email sending" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
{% for k, v in request.POST.items %} {% for k, v in request.POST.items %}
@@ -4,7 +4,7 @@
{% load hierarkey_form %} {% load hierarkey_form %}
{% load static %} {% load static %}
{% block inside %} {% block inside %}
<h1>{% trans "E-mail settings" %}</h1> <h1>{% trans "Email settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data" <form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
mail-preview-url="{% url "control:event.settings.mail.preview" event=request.event.slug organizer=request.event.organizer.slug %}"> mail-preview-url="{% url "control:event.settings.mail.preview" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %} {% csrf_token %}
@@ -63,7 +63,7 @@
{% bootstrap_field form.mail_attach_ical_description layout="control" %} {% bootstrap_field form.mail_attach_ical_description layout="control" %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "E-mail design" %}</legend> <legend>{% trans "Email design" %}</legend>
<div class="row"> <div class="row">
{% for r in renderers.values %} {% for r in renderers.values %}
<div class="col-md-3"> <div class="col-md-3">
@@ -84,7 +84,7 @@
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "E-mail content" %}</legend> <legend>{% trans "Email content" %}</legend>
<h4>{% trans "Text" %}</h4> <h4>{% trans "Text" %}</h4>
<div class="panel-group" id="questions_group"> <div class="panel-group" id="questions_group">
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %} {% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
@@ -22,7 +22,7 @@
{% bootstrap_field form.name layout="control" %} {% bootstrap_field form.name layout="control" %}
{% bootstrap_field form.slug layout="control" %} {% bootstrap_field form.slug layout="control" %}
{% if form.domain %} {% if form.domain %}
{% bootstrap_field form.domain layout="control" %} {% bootstrap_field form.domain layout="horizontal" %}
{% endif %} {% endif %}
{% bootstrap_field form.date_from layout="control" %} {% bootstrap_field form.date_from layout="control" %}
{% bootstrap_field form.date_to layout="control" %} {% bootstrap_field form.date_to layout="control" %}
@@ -67,7 +67,7 @@
<h4>{% trans "Customer data (once per order)" %}</h4> <h4>{% trans "Customer data (once per order)" %}</h4>
<div class="form-group"> <div class="form-group">
<label class="control-label col-md-3"> <label class="control-label col-md-3">
{% trans "E-mail" %} {% trans "Email" %}
</label> </label>
<div class="col-md-9"> <div class="col-md-9">
<div class="checkbox"> <div class="checkbox">
@@ -41,7 +41,10 @@
{% endif %} {% endif %}
{% include "pretixcontrol/event/fragment_geodata.html" %} {% include "pretixcontrol/event/fragment_geodata.html" %}
{% bootstrap_field form.currency layout="control" %} {% bootstrap_field form.currency layout="control" %}
{% bootstrap_field form.tax_rate addon_after="%" layout="control" %} {% bootstrap_field form.no_taxes layout="control" %}
<div data-display-dependency="#{{ form.no_taxes.id_for_label }}" data-inverse>
{% bootstrap_field form.tax_rate addon_after="%" layout="control" %}
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Display settings" %}</legend> <legend>{% trans "Display settings" %}</legend>
@@ -46,11 +46,13 @@
<div id="cp{{ pos.id }}"> <div id="cp{{ pos.id }}">
<div class="panel-body"> <div class="panel-body">
{% for form in forms %} {% for form in forms %}
{% if form.pos.item != pos.item %} <div class="profile-scope">
{# Add-Ons #} {% if form.pos.item != pos.item %}
<legend>+ {{ form.pos.item }}</legend> {# Add-Ons #}
{% endif %} <legend>+ {{ form.pos.item }}</legend>
{% bootstrap_form form layout="control" %} {% endif %}
{% bootstrap_form form layout="control" %}
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -24,7 +24,7 @@
{% endif %} {% endif %}
{% if request.method == "POST" %} {% if request.method == "POST" %}
<fieldset> <fieldset>
<legend>{% trans "E-mail preview" %}</legend> <legend>{% trans "Email preview" %}</legend>
<div class="tab-pane mail-preview-group"> <div class="tab-pane mail-preview-group">
<div lang="{{ order.locale }}" class="mail-preview"> <div lang="{{ order.locale }}" class="mail-preview">
<strong>{{ preview_output.subject }}</strong><br><br> <strong>{{ preview_output.subject }}</strong><br><br>
@@ -46,7 +46,7 @@
{% trans "active" %} {% trans "active" %}
{% endif %} {% endif %}
</dd> </dd>
<dt>{% trans "E-mail" %}</dt> <dt>{% trans "Email" %}</dt>
<dd> <dd>
{{ customer.email|default_if_none:"" }} {{ customer.email|default_if_none:"" }}
{% if customer.email and not customer.provider %} {% if customer.email and not customer.provider %}
@@ -294,6 +294,71 @@
<legend>{% trans "Invoices" %}</legend> <legend>{% trans "Invoices" %}</legend>
{% bootstrap_field sform.invoice_regenerate_allowed layout="control" %} {% bootstrap_field sform.invoice_regenerate_allowed layout="control" %}
</fieldset> </fieldset>
{% if domain_formset %}
<fieldset>
<legend>{% trans "Domains" %}</legend>
<div class="alert alert-warning">
{% trans "This dialog is intended for advanced users." %}
{% trans "The domain needs to be configured on your webserver before it can be used here." %}
</div>
<div class="formset" data-formset data-formset-prefix="{{ domain_formset.prefix }}">
{{ domain_formset.management_form }}
{% bootstrap_formset_errors domain_formset %}
<div data-formset-body>
{% for form in domain_formset %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.domainname layout='' form_group_class="" %}
{% bootstrap_form_errors form %}
</div>
<div class="col-md-3">
{% bootstrap_field form.mode layout='' form_group_class="" %}
</div>
<div class="col-md-3">
{% bootstrap_field form.event layout='' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<label aria-hidden="true">&nbsp;</label><br>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ domain_formset.empty_form.id }}
{% bootstrap_field domain_formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-4">
{% bootstrap_field domain_formset.empty_form.domainname layout='' form_group_class="" %}
</div>
<div class="col-md-3">
{% bootstrap_field domain_formset.empty_form.mode layout='' form_group_class="" %}
</div>
<div class="col-md-3">
{% bootstrap_field domain_formset.empty_form.event layout='' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<label aria-hidden="true">&nbsp;</label><br>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add domain" %}</button>
</p>
</fieldset>
{% endif %}
</div> </div>
<div class="form-group submit-group"> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">
@@ -8,7 +8,7 @@
{% endblock %} {% endblock %}
{% block title %}{% trans "Organizer" %}{% endblock %} {% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "E-mail settings" %}</h1> <h1>{% trans "Email settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data" <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 %}">
@@ -55,7 +55,7 @@
{% bootstrap_field form.mail_bcc layout="control" %} {% bootstrap_field form.mail_bcc layout="control" %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "E-mail content" %}</legend> <legend>{% trans "Email content" %}</legend>
<div class="panel-group" id="questions_group"> <div class="panel-group" id="questions_group">
{% blocktrans asvar title_customer_registration %}Customer account registration{% endblocktrans %} {% blocktrans asvar title_customer_registration %}Customer account registration{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_subject_customer_registration,mail_text_customer_registration" %} {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_subject_customer_registration,mail_text_customer_registration" %}
@@ -54,7 +54,7 @@
<thead> <thead>
<tr> <tr>
<th>{% trans "Notification type" %}</th> <th>{% trans "Notification type" %}</th>
<th class="text-center">{% trans "E-Mail notification" %}</th> <th class="text-center">{% trans "Email notification" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -39,7 +39,7 @@
<thead> <thead>
<tr> <tr>
<th> <th>
{% trans "E-mail address" %} {% trans "Email address" %}
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a> <a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a> <a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a>
</th> </th>
+3 -3
View File
@@ -343,7 +343,7 @@ class Forgot(TemplateView):
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.') logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
except SendMailException: except SendMailException:
logger.exception('Sending password reset e-mail to \"' + email + '\" failed.') logger.exception('Sending password reset email to \"' + email + '\" failed.')
except RepeatedResetDenied: except RepeatedResetDenied:
pass pass
@@ -354,10 +354,10 @@ class Forgot(TemplateView):
finally: finally:
if has_redis: if has_redis:
messages.info(request, _('If the address is registered to valid account, then we have sent you an e-mail containing further instructions. ' messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions. '
'Please note that we will send at most one email every 24 hours.')) 'Please note that we will send at most one email every 24 hours.'))
else: else:
messages.info(request, _('If the address is registered to valid account, then we have sent you an e-mail containing further instructions.')) messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions.'))
return redirect('control:auth.forgot') return redirect('control:auth.forgot')
else: else:
+7 -5
View File
@@ -49,6 +49,7 @@ from django.views.generic import FormView, ListView, TemplateView
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.api.views.checkin import _redeem_process from pretix.api.views.checkin import _redeem_process
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, Order, OrderPosition from pretix.base.models import Checkin, Order, OrderPosition
from pretix.base.models.checkin import CheckinList from pretix.base.models.checkin import CheckinList
from pretix.base.services.checkin import ( from pretix.base.services.checkin import (
@@ -81,9 +82,7 @@ class CheckInListQueryMixin:
position_id=OuterRef('pk'), position_id=OuterRef('pk'),
list_id=self.list.pk, list_id=self.list.pk,
type=Checkin.TYPE_ENTRY type=Checkin.TYPE_ENTRY
).order_by().values('position_id').annotate( ).order_by('-datetime').values('position_id')
m=Max('datetime')
).values('m')
cqs_exit = Checkin.objects.filter( cqs_exit = Checkin.objects.filter(
position_id=OuterRef('pk'), position_id=OuterRef('pk'),
list_id=self.list.pk, list_id=self.list.pk,
@@ -103,7 +102,7 @@ class CheckInListQueryMixin:
status_q, status_q,
order__event=self.request.event, order__event=self.request.event,
).annotate( ).annotate(
last_entry=Subquery(cqs), last_entry=Subquery(cqs[:1].values('datetime')),
last_exit=Subquery(cqs_exit), last_exit=Subquery(cqs_exit),
auto_checked_in=Exists( auto_checked_in=Exists(
Checkin.objects.filter( Checkin.objects.filter(
@@ -112,7 +111,8 @@ class CheckInListQueryMixin:
list_id=self.list.pk, list_id=self.list.pk,
auto_checked_in=True auto_checked_in=True
) )
) ),
last_entry_source_type=Subquery(cqs[:1].values('raw_source_type'))
).select_related( ).select_related(
'item', 'variation', 'order', 'addon_to' 'item', 'variation', 'order', 'addon_to'
).prefetch_related( ).prefetch_related(
@@ -157,6 +157,7 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, CheckInList
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['media_types'] = MEDIA_TYPES
ctx['checkinlist'] = self.list ctx['checkinlist'] = self.list
if self.request.event.has_subevents: if self.request.event.has_subevents:
ctx['seats'] = ( ctx['seats'] = (
@@ -497,6 +498,7 @@ class CheckinListView(EventPermissionRequiredMixin, PaginationMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data() ctx = super().get_context_data()
ctx['filter_form'] = self.filter_form ctx['filter_form'] = self.filter_form
ctx['media_types'] = MEDIA_TYPES
return ctx return ctx
+11 -8
View File
@@ -38,6 +38,7 @@ from zoneinfo import ZoneInfo
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import intcomma
from django.db.models import ( from django.db.models import (
Count, IntegerField, Max, Min, OuterRef, Prefetch, Q, Subquery, Sum, Count, IntegerField, Max, Min, OuterRef, Prefetch, Q, Subquery, Sum,
) )
@@ -47,7 +48,6 @@ from django.http import JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.template.loader import get_template from django.template.loader import get_template
from django.urls import reverse from django.urls import reverse
from django.utils import formats
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
@@ -67,6 +67,7 @@ from pretix.control.signals import (
from pretix.helpers.daterange import daterange from pretix.helpers.daterange import daterange
from ...base.models.orders import CancellationRequest from ...base.models.orders import CancellationRequest
from ...base.templatetags.money import money_filter
from ..logdisplay import OVERVIEW_BANLIST from ..logdisplay import OVERVIEW_BANLIST
NUM_WIDGET = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>' NUM_WIDGET = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>'
@@ -111,7 +112,7 @@ def base_widgets(sender, subevent=None, lazy=False, **kwargs):
return [ return [
{ {
'content': None if lazy else NUM_WIDGET.format(num=tickc, text=_('Attendees (ordered)')), 'content': None if lazy else NUM_WIDGET.format(num=intcomma(tickc), text=_('Attendees (ordered)')),
'lazy': 'attendees-ordered', 'lazy': 'attendees-ordered',
'display_size': 'small', 'display_size': 'small',
'priority': 100, 'priority': 100,
@@ -121,7 +122,7 @@ def base_widgets(sender, subevent=None, lazy=False, **kwargs):
}) + ('?subevent={}'.format(subevent.pk) if subevent else '') }) + ('?subevent={}'.format(subevent.pk) if subevent else '')
}, },
{ {
'content': None if lazy else NUM_WIDGET.format(num=paidc, text=_('Attendees (paid)')), 'content': None if lazy else NUM_WIDGET.format(num=intcomma(paidc), text=_('Attendees (paid)')),
'lazy': 'attendees-paid', 'lazy': 'attendees-paid',
'display_size': 'small', 'display_size': 'small',
'priority': 100, 'priority': 100,
@@ -132,7 +133,9 @@ def base_widgets(sender, subevent=None, lazy=False, **kwargs):
}, },
{ {
'content': None if lazy else NUM_WIDGET.format( 'content': None if lazy else NUM_WIDGET.format(
num=formats.localize(round_decimal(rev, sender.currency)), text=_('Total revenue ({currency})').format(currency=sender.currency)), num=money_filter(round_decimal(rev, sender.currency), sender.currency, hide_currency=True),
text=_('Total revenue ({currency})').format(currency=sender.currency)
),
'lazy': 'total-revenue', 'lazy': 'total-revenue',
'display_size': 'small', 'display_size': 'small',
'priority': 100, 'priority': 100,
@@ -207,7 +210,7 @@ def waitinglist_widgets(sender, subevent=None, lazy=False, **kwargs):
widgets.append({ widgets.append({
'content': None if lazy else NUM_WIDGET.format( 'content': None if lazy else NUM_WIDGET.format(
num=str(happy), text=_('available to give to people on waiting list') num=intcomma(happy), text=_('available to give to people on waiting list')
), ),
'lazy': 'waitinglist-avail', 'lazy': 'waitinglist-avail',
'priority': 50, 'priority': 50,
@@ -217,7 +220,7 @@ def waitinglist_widgets(sender, subevent=None, lazy=False, **kwargs):
}) })
}) })
widgets.append({ widgets.append({
'content': None if lazy else NUM_WIDGET.format(num=str(wles.count()), text=_('total waiting list length')), 'content': None if lazy else NUM_WIDGET.format(num=intcomma(wles.count()), text=_('total waiting list length')),
'lazy': 'waitinglist-length', 'lazy': 'waitinglist-length',
'display_size': 'small', 'display_size': 'small',
'priority': 50, 'priority': 50,
@@ -245,7 +248,7 @@ def quota_widgets(sender, subevent=None, lazy=False, **kwargs):
status, left = qa.results[q] if q in qa.results else q.availability(allow_cache=True) status, left = qa.results[q] if q in qa.results else q.availability(allow_cache=True)
widgets.append({ widgets.append({
'content': None if lazy else NUM_WIDGET.format( 'content': None if lazy else NUM_WIDGET.format(
num='{}/{}'.format(left, q.size) if q.size is not None else '\u221e', num='{}/{}'.format(intcomma(left), intcomma(q.size)) if q.size is not None else '\u221e',
text=_('{quota} left').format(quota=escape(q.name)) text=_('{quota} left').format(quota=escape(q.name))
), ),
'lazy': 'quota-{}'.format(q.pk), 'lazy': 'quota-{}'.format(q.pk),
@@ -297,7 +300,7 @@ def checkin_widget(sender, subevent=None, lazy=False, **kwargs):
for cl in qs: for cl in qs:
widgets.append({ widgets.append({
'content': None if lazy else NUM_WIDGET.format( 'content': None if lazy else NUM_WIDGET.format(
num='{}/{}'.format(cl.inside_count, cl.position_count), num='{}/{}'.format(intcomma(cl.inside_count), intcomma(cl.position_count)),
text=_('Present {list}').format(list=escape(cl.name)) text=_('Present {list}').format(list=escape(cl.name))
), ),
'lazy': 'checkin-{}'.format(cl.pk), 'lazy': 'checkin-{}'.format(cl.pk),
+25 -24
View File
@@ -62,7 +62,7 @@ from django.http import (
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape from django.utils.html import conditional_escape
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _, gettext_noop from django.utils.translation import gettext, gettext_lazy as _, gettext_noop
@@ -100,9 +100,12 @@ from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota, Item, ItemCategory, ItemMetaProperty, Question, Quota,
) )
from ...base.services.mail import prefix_subject from ...base.services.mail import prefix_subject
from ...base.services.placeholders import get_sample_context
from ...base.settings import LazyI18nStringList from ...base.settings import LazyI18nStringList
from ...helpers.compat import CompatDeleteView from ...helpers.compat import CompatDeleteView
from ...helpers.format import format_map from ...helpers.format import (
PlainHtmlAlternativeString, SafeFormatter, format_map,
)
from ..logdisplay import OVERVIEW_BANLIST from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, PaginationMixin, UpdateView from . import CreateView, PaginationMixin, UpdateView
@@ -239,7 +242,6 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
if self.request.user.has_active_staff_session(self.request.session.session_key): if self.request.user.has_active_staff_session(self.request.session.session_key):
kwargs['change_slug'] = True kwargs['change_slug'] = True
kwargs['domain'] = True
return kwargs return kwargs
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -717,20 +719,7 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
# get all supported placeholders with dummy values # get all supported placeholders with dummy values
def placeholders(self, item): def placeholders(self, item):
ctx = {} return get_sample_context(self.request.event, MailSettingsForm.base_context[item])
for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item]).values():
s = str(p.render_sample(self.request.event))
if s.strip().startswith('* '):
ctx[p.identifier] = '<div class="placeholder" title="{}">{}</div>'.format(
_('This value will be replaced based on dynamic parameters.'),
markdown_compile_email(s)
)
else:
ctx[p.identifier] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
escape(s)
)
return ctx
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
preview_item = request.POST.get('item', '') preview_item = request.POST.get('item', '')
@@ -752,9 +741,15 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
bleach.clean(v), self.placeholders(preview_item), raise_on_missing=True bleach.clean(v), self.placeholders(preview_item), raise_on_missing=True
), highlight=True) ), highlight=True)
else: else:
msgs[self.supported_locale[idx]] = markdown_compile_email( placeholders = self.placeholders(preview_item)
format_map(v, self.placeholders(preview_item), raise_on_missing=True) msgs[self.supported_locale[idx]] = format_map(
markdown_compile_email(
format_map(v, placeholders, raise_on_missing=True)
),
placeholders,
mode=SafeFormatter.MODE_RICH_TO_HTML,
) )
except ValueError: except ValueError:
msgs[self.supported_locale[idx]] = '<div class="alert alert-danger">{}</div>'.format( msgs[self.supported_locale[idx]] = '<div class="alert alert-danger">{}</div>'.format(
PlaceholderValidator.error_message) PlaceholderValidator.error_message)
@@ -777,13 +772,18 @@ class MailSettingsRendererPreview(MailSettingsPreview):
# get all supported placeholders with dummy values # get all supported placeholders with dummy values
def placeholders(self, item): def placeholders(self, item):
ctx = {} ctx = {}
for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item]).values(): for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item], rich=True).values():
ctx[p.identifier] = escape(str(p.render_sample(self.request.event))) sample = p.render_sample(self.request.event)
if isinstance(sample, PlainHtmlAlternativeString):
ctx[p.identifier] = sample
else:
ctx[p.identifier] = conditional_escape(sample)
return ctx return ctx
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
v = str(request.event.settings.mail_text_order_placed) v = str(request.event.settings.mail_text_order_placed)
v = format_map(v, self.placeholders('mail_text_order_placed')) context = self.placeholders('mail_text_order_placed')
v = format_map(v, context)
renderers = request.event.get_html_mail_renderers() renderers = request.event.get_html_mail_renderers()
if request.GET.get('renderer') in renderers: if request.GET.get('renderer') in renderers:
with rolledback_transaction(): with rolledback_transaction():
@@ -801,13 +801,14 @@ class MailSettingsRendererPreview(MailSettingsPreview):
str(request.event.settings.mail_text_signature), str(request.event.settings.mail_text_signature),
gettext('Your order: %(code)s') % {'code': order.code}, gettext('Your order: %(code)s') % {'code': order.code},
order, order,
position=None position=None,
context=context,
) )
r = HttpResponse(v, content_type='text/html') r = HttpResponse(v, content_type='text/html')
r._csp_ignore = True r._csp_ignore = True
return r return r
else: else:
raise Http404(_('Unknown e-mail renderer.')) raise Http404(_('Unknown email renderer.'))
class TicketSettingsPreview(EventPermissionRequiredMixin, View): class TicketSettingsPreview(EventPermissionRequiredMixin, View):
+2 -2
View File
@@ -134,7 +134,7 @@ from pretix.control.signals import order_search_forms
from pretix.control.views import PaginationMixin from pretix.control.views import PaginationMixin
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.compat import CompatDeleteView from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.format import format_map from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.safedownload import check_token from pretix.helpers.safedownload import check_token
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
@@ -2351,7 +2351,7 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
'subject': mark_safe(_('Subject: {subject}').format( 'subject': mark_safe(_('Subject: {subject}').format(
subject=prefix_subject(order.event, escape(email_subject), highlight=True) subject=prefix_subject(order.event, escape(email_subject), highlight=True)
)), )),
'html': markdown_compile_email(email_content) 'html': format_map(markdown_compile_email(email_content), email_context, mode=SafeFormatter.MODE_RICH_TO_HTML)
} }
return self.get(self.request, *self.args, **self.kwargs) return self.get(self.request, *self.args, **self.kwargs)
else: else:
+37 -11
View File
@@ -104,11 +104,11 @@ from pretix.control.forms.organizer import (
CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm, CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm,
EventMetaPropertyAllowedValueFormSet, EventMetaPropertyForm, GateForm, EventMetaPropertyAllowedValueFormSet, EventMetaPropertyForm, GateForm,
GiftCardAcceptanceInviteForm, GiftCardCreateForm, GiftCardUpdateForm, GiftCardAcceptanceInviteForm, GiftCardCreateForm, GiftCardUpdateForm,
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm, KnownDomainFormset, MailSettingsForm, MembershipTypeForm,
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm, MembershipUpdateForm, OrganizerDeleteForm, OrganizerFooterLinkFormset,
OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm, OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm,
ReusableMediumUpdateForm, SalesChannelForm, SSOClientForm, SSOProviderForm, ReusableMediumCreateForm, ReusableMediumUpdateForm, SalesChannelForm,
TeamForm, WebHookForm, SSOClientForm, SSOProviderForm, TeamForm, WebHookForm,
) )
from pretix.control.forms.rrule import RRuleForm from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.logdisplay import OVERVIEW_BANLIST
@@ -122,7 +122,7 @@ from pretix.control.views.mailsetup import MailSettingsSetupView
from pretix.helpers import OF_SELF, GroupConcat from pretix.helpers import OF_SELF, GroupConcat
from pretix.helpers.compat import CompatDeleteView from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
from pretix.helpers.format import format_map from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.forms.customer import TokenGenerator from pretix.presale.forms.customer import TokenGenerator
@@ -357,9 +357,10 @@ class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
highlight=True, highlight=True,
) )
else: else:
msgs[self.supported_locale[idx]] = markdown_compile_email( placeholders = self.placeholders(preview_item)
format_map(v, self.placeholders(preview_item)) msgs[self.supported_locale[idx]] = format_map(markdown_compile_email(
) format_map(v, placeholders)
), placeholders, mode=SafeFormatter.MODE_RICH_TO_HTML)
return JsonResponse({ return JsonResponse({
'item': preview_item, 'item': preview_item,
@@ -447,6 +448,10 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
def get_object(self, queryset=None) -> Organizer: def get_object(self, queryset=None) -> Organizer:
return self.object return self.object
@cached_property
def domain_config(self):
return self.request.user.has_active_staff_session(self.request.session.session_key)
@cached_property @cached_property
def sform(self): def sform(self):
return OrganizerSettingsForm( return OrganizerSettingsForm(
@@ -461,6 +466,8 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
context['sform'] = self.sform context['sform'] = self.sform
context['footer_links_formset'] = self.footer_links_formset context['footer_links_formset'] = self.footer_links_formset
if self.domain_config:
context['domain_formset'] = self.domain_formset
return context return context
@transaction.atomic @transaction.atomic
@@ -483,6 +490,8 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
self.request.organizer.log_action('pretix.organizer.footerlinks.changed', user=self.request.user, data={ self.request.organizer.log_action('pretix.organizer.footerlinks.changed', user=self.request.user, data={
'data': self.footer_links_formset.cleaned_data 'data': self.footer_links_formset.cleaned_data
}) })
if self.domain_config and self.domain_formset.has_changed():
self._save_domain_config()
if form.has_changed(): if form.has_changed():
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.organizer.changed', 'pretix.organizer.changed',
@@ -493,10 +502,22 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form) return super().form_valid(form)
def _save_domain_config(self):
for form in self.domain_formset.initial_forms:
if form.instance.pk and form.has_changed():
self.object.domains.get(pk=form.instance.pk).log_delete(self.request.user)
self.domain_formset.save()
for new_obj in self.domain_formset.new_objects:
new_obj.log_create(self.request.user)
for ch_obj, form in self.domain_formset.changed_objects:
ch_obj.log_create(self.request.user)
self.request.organizer.cache.clear()
for ev in self.request.organizer.events.all():
ev.cache.clear()
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
if self.request.user.has_active_staff_session(self.request.session.session_key): if self.request.user.has_active_staff_session(self.request.session.session_key):
kwargs['domain'] = True
kwargs['change_slug'] = True kwargs['change_slug'] = True
return kwargs return kwargs
@@ -508,7 +529,7 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
form = self.get_form() form = self.get_form()
if form.is_valid() and self.sform.is_valid() and self.footer_links_formset.is_valid(): if form.is_valid() and self.sform.is_valid() and self.footer_links_formset.is_valid() and (not self.domain_config or self.domain_formset.is_valid()):
return self.form_valid(form) return self.form_valid(form)
else: else:
return self.form_invalid(form) return self.form_invalid(form)
@@ -519,6 +540,11 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
organizer=self.object, organizer=self.object,
prefix="footer-links", instance=self.object) prefix="footer-links", instance=self.object)
@cached_property
def domain_formset(self):
return KnownDomainFormset(self.request.POST if self.request.method == "POST" else None, prefix="domains",
instance=self.object, organizer=self.object)
def save_footer_links_formset(self, obj): def save_footer_links_formset(self, obj):
self.footer_links_formset.save() self.footer_links_formset.save()
+1 -1
View File
@@ -145,7 +145,7 @@ class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRe
self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent', self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent',
user=request.user) user=request.user)
messages.success(request, _('We sent out an e-mail containing further instructions.')) messages.success(request, _('We sent out an email containing further instructions.'))
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def get_success_url(self): def get_success_url(self):
+8 -19
View File
@@ -50,7 +50,7 @@ from django.http import (
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import resolve, reverse from django.urls import resolve, reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape, format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -59,12 +59,12 @@ from django.views.generic import (
) )
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.email import get_available_placeholders
from pretix.base.models import ( from pretix.base.models import (
CartPosition, LogEntry, Voucher, WaitingListEntry, CartPosition, LogEntry, Voucher, WaitingListEntry,
) )
from pretix.base.models.vouchers import generate_codes from pretix.base.models.vouchers import generate_codes
from pretix.base.services.mail import prefix_subject from pretix.base.services.mail import prefix_subject
from pretix.base.services.placeholders import get_sample_context
from pretix.base.services.vouchers import vouchers_send from pretix.base.services.vouchers import vouchers_send
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncFormView from pretix.base.views.tasks import AsyncFormView
@@ -74,7 +74,7 @@ from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import voucher_form_class from pretix.control.signals import voucher_form_class
from pretix.control.views import PaginationMixin from pretix.control.views import PaginationMixin
from pretix.helpers.compat import CompatDeleteView from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.format import format_map from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -549,22 +549,10 @@ class VoucherBulkMailPreview(EventPermissionRequiredMixin, View):
# get all supported placeholders with dummy values # get all supported placeholders with dummy values
def placeholders(self, item): def placeholders(self, item):
ctx = {}
base_ctx = ['event', 'name'] base_ctx = ['event', 'name']
if item == 'send_message': if item == 'send_message':
base_ctx += ['voucher_list'] base_ctx += ['voucher_list']
for p in get_available_placeholders(self.request.event, base_ctx).values(): ctx = get_sample_context(self.request.event, base_ctx)
s = str(p.render_sample(self.request.event))
if s.strip().startswith('* ') or s.startswith(' '):
ctx[p.identifier] = '<div class="placeholder" title="{}">{}</div>'.format(
_('This value will be replaced based on dynamic parameters.'),
markdown_compile_email(s)
)
else:
ctx[p.identifier] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
escape(s)
)
return self.SafeDict(ctx) return self.SafeDict(ctx)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -579,9 +567,10 @@ class VoucherBulkMailPreview(EventPermissionRequiredMixin, View):
highlight=True highlight=True
) )
else: else:
msgs["all"] = markdown_compile_email( placeholders = self.placeholders(preview_item)
format_map(request.POST.get(preview_item), self.placeholders(preview_item)) msgs["all"] = format_map(markdown_compile_email(
) format_map(request.POST.get(preview_item), placeholders)
), placeholders, mode=SafeFormatter.MODE_RICH_TO_HTML)
return JsonResponse({ return JsonResponse({
'item': preview_item, 'item': preview_item,
+1 -1
View File
@@ -304,7 +304,7 @@ class WaitingListView(EventPermissionRequiredMixin, WaitingListQuerySetMixin, Pa
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",") writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
headers = [ headers = [
_('Name'), _('E-mail address'), _('Phone number'), _('Product'), _('On list since'), _('Status'), _('Voucher code'), _('Name'), _('Email address'), _('Phone number'), _('Product'), _('On list since'), _('Status'), _('Voucher code'),
_('Language'), _('Priority') _('Language'), _('Priority')
] ]
if self.request.event.has_subevents: if self.request.event.has_subevents:
+28 -5
View File
@@ -25,14 +25,29 @@ from string import Formatter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class PlainHtmlAlternativeString:
def __init__(self, plain, html, is_block=False):
self.plain = plain
self.html = html
self.is_block = is_block
def __repr__(self):
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
class SafeFormatter(Formatter): class SafeFormatter(Formatter):
""" """
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
(b) does not allow any unwanted shenanigans like attribute access or format specifiers. (b) does not allow any unwanted shenanigans like attribute access or format specifiers.
""" """
def __init__(self, context, raise_on_missing=False): MODE_IGNORE_RICH = 0
MODE_RICH_TO_PLAIN = 1
MODE_RICH_TO_HTML = 2
def __init__(self, context, raise_on_missing=False, mode=MODE_IGNORE_RICH):
self.context = context self.context = context
self.raise_on_missing = raise_on_missing self.raise_on_missing = raise_on_missing
self.mode = mode
def get_field(self, field_name, args, kwargs): def get_field(self, field_name, args, kwargs):
return self.get_value(field_name, args, kwargs), field_name return self.get_value(field_name, args, kwargs), field_name
@@ -40,14 +55,22 @@ class SafeFormatter(Formatter):
def get_value(self, key, args, kwargs): def get_value(self, key, args, kwargs):
if not self.raise_on_missing and key not in self.context: if not self.raise_on_missing and key not in self.context:
return '{' + str(key) + '}' return '{' + str(key) + '}'
return self.context[key] r = self.context[key]
if isinstance(r, PlainHtmlAlternativeString):
if self.mode == self.MODE_IGNORE_RICH:
return '{' + str(key) + '}'
elif self.mode == self.MODE_RICH_TO_PLAIN:
return r.plain
elif self.mode == self.MODE_RICH_TO_HTML:
return r.html
return r
def format_field(self, value, format_spec): def format_field(self, value, format_spec):
# Ignore format _spec # Ignore format_spec
return super().format_field(value, '') return super().format_field(value, '')
def format_map(template, context, raise_on_missing=False): def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_IGNORE_RICH):
if not isinstance(template, str): if not isinstance(template, str):
template = str(template) template = str(template)
return SafeFormatter(context, raise_on_missing).format(template) return SafeFormatter(context, raise_on_missing, mode=mode).format(template)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -198,6 +198,7 @@ Mitgliedschafts
Mitgliedschaftsdauer Mitgliedschaftsdauer
min min
Mo Mo
MobilePay
MOTO MOTO
Multibanco Multibanco
MwSt MwSt
File diff suppressed because it is too large Load Diff
@@ -198,6 +198,7 @@ Mitgliedschafts
Mitgliedschaftsdauer Mitgliedschaftsdauer
min min
Mo Mo
MobilePay
MOTO MOTO
Multibanco Multibanco
MwSt MwSt
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-08 13:45+0000\n" "POT-Creation-Date: 2024-11-19 15:34+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-08 13:45+0000\n" "POT-Creation-Date: 2024-11-08 13:45+0000\n"
"PO-Revision-Date: 2024-11-13 21:00+0000\n" "PO-Revision-Date: 2024-11-18 15:48+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n" "Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/" "Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/"
"pretix-js/es/>\n" "pretix-js/es/>\n"
@@ -448,7 +448,7 @@ msgstr "Producto"
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:103 #: pretix/static/pretixcontrol/js/ui/checkinrules.js:103
msgid "Product variation" msgid "Product variation"
msgstr "Ver variaciones del producto" msgstr "Variación del producto"
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:107 #: pretix/static/pretixcontrol/js/ui/checkinrules.js:107
msgid "Gate" msgid "Gate"
@@ -645,7 +645,7 @@ msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:496 #: pretix/static/pretixcontrol/js/ui/main.js:496
#: pretix/static/pretixcontrol/js/ui/main.js:516 #: pretix/static/pretixcontrol/js/ui/main.js:516
msgid "Search query" msgid "Search query"
msgstr "Consultar búsqueda" msgstr "Consulta de búsqueda"
#: pretix/static/pretixcontrol/js/ui/main.js:514 #: pretix/static/pretixcontrol/js/ui/main.js:514
msgid "All" msgid "All"
@@ -1058,7 +1058,7 @@ msgstr "febrero"
#: pretix/static/pretixpresale/js/widget/widget.js:80 #: pretix/static/pretixpresale/js/widget/widget.js:80
msgid "March" msgid "March"
msgstr "marzo" msgstr "Marzo"
#: pretix/static/pretixpresale/js/widget/widget.js:81 #: pretix/static/pretixpresale/js/widget/widget.js:81
msgid "April" msgid "April"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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