Compare commits

...

139 Commits

Author SHA1 Message Date
Mira Weller
7648cb2e35 Use different log action types per log_target for mail errors (Z#23204190) 2025-09-02 11:49:42 +02:00
Richard Schreiber
b7803565d6 Fix PayPal2 payment creation for free cart (#5415) 2025-09-02 09:53:24 +02:00
Richard Schreiber
f3b6627e63 Fix handling zero-duration events in organizer day-calendar (#5414) 2025-09-02 09:51:05 +02:00
Renne Rocha
574513550d Translations: Update Portuguese (Brazil)
Currently translated at 91.0% (5525 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
z3rrry
f145d447a2 Translations: Update Korean
Currently translated at 50.8% (3087 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
CVZ-es
72b9b49b9d Translations: Update Spanish
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
CVZ-es
6d20d0e840 Translations: Update Spanish
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
patch-works-be
4a662a1aa1 Translations: Update French
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
CVZ-es
8213b09847 Translations: Update French
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Yasunobu YesNo Kawaguchi
c54f776b39 Translations: Update Japanese
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
CVZ-es
fdd03536f2 Translations: Update Spanish
Currently translated at 97.1% (5894 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
CVZ-es
44303a0030 Translations: Update French
Currently translated at 100.0% (252 of 252 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Renne Rocha
5ba10416ce Translations: Update Portuguese (Brazil)
Currently translated at 90.7% (5507 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
CVZ-es
efa117c836 Translations: Update French
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Yasunobu YesNo Kawaguchi
70cd2265db Translations: Update Japanese
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Renne Rocha
b5afbfa1bf Translations: Update Portuguese (Brazil)
Currently translated at 100.0% (252 of 252 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Renne Rocha
2dffe0e2c8 Translations: Update Portuguese (Brazil)
Currently translated at 88.8% (5392 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
CVZ-es
df0e0f9115 Translations: Update French
Currently translated at 99.2% (6023 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Mira
2fc47c5d71 Translations: Update French
Currently translated at 99.2% (6023 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
CVZ-es
c23d2e5504 Translations: Update French
Currently translated at 98.3% (5965 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Mie Frydensbjerg
58c7e3d316 Translations: Update Danish
Currently translated at 46.1% (2802 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Yasunobu YesNo Kawaguchi
2d5c3fbea6 Translations: Update Japanese
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Jan Van Haver
222851620e Translations: Update Dutch
Currently translated at 97.0% (5888 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Jan Van Haver
9ac772b2f3 Translations: Update Dutch
Currently translated at 96.8% (5876 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Yasunobu YesNo Kawaguchi
1408f31ec5 Translations: Update Japanese
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Yasunobu YesNo Kawaguchi
04f32284a8 Translations: Update Japanese
Currently translated at 100.0% (252 of 252 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Yasunobu YesNo Kawaguchi
318b80c3a5 Translations: Update Japanese
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Luca Sorace \"Stranck
102d172942 Translations: Update Italian
Currently translated at 37.0% (2251 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Yasunobu YesNo Kawaguchi
c084698821 Translations: Update Japanese
Currently translated at 98.3% (5968 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Raphael Michel
edffe5c9dd Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-09-02 09:23:02 +02:00
Richard Schreiber
09e9273a57 Fix unhandled not found error when manually managing sync jobs (#5412)
* Fix unhandled not found error when manually managing sync jobs

* Improve info text (suggestions from code review)

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

---------

Co-authored-by: luelista <weller@rami.io>
2025-09-02 09:22:47 +02:00
Richard Schreiber
24ac588119 Remove unnecessary translation for daterange (Z#23205453) (#5410)
* Remove unnecessary translation for daterange

* fix flake8

* fix isort
2025-09-02 09:22:21 +02:00
Richard Schreiber
d23735b1a6 Fix missing payment provider "banktransfer" on export (#5405) 2025-08-26 11:40:24 +02:00
Raphael Michel
d8156186d8 Bump hierarkey to 2.0.1 2025-08-23 09:07:33 +02:00
Raphael Michel
abab7e5bc6 Bank transfer: Do not check for events 2025-08-22 12:47:34 +02:00
Raphael Michel
f89a33862a asynctask.js: Fix gettext being used before translations are loaded (Z#23204825) (#5401) 2025-08-22 10:48:53 +02:00
Raphael Michel
deb7cfa899 Bank transfer: Migrate to a hybrid plugin (#5394)
* Bank transfer: Migrate to a hybrid plugin

* Fix failing tests

* Fix test fixtures

* Add missing fixture
2025-08-22 10:47:52 +02:00
Raphael Michel
3f00fa58a0 Subevents: set inactive if non-batch deletion of subevent fails (Z#23204183) (#5374)
* Subevents: Extend fallback for undeletable dates for single deletion (Z#23204183)

* Fix useless writes

* Update src/pretix/control/views/subevents.py

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

* Update src/pretix/control/views/subevents.py

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

* Fix flow

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-08-22 10:47:47 +02:00
luelista
49c0f6b967 Organizer plugins: Do not show plugins as active if they are inactive on org-level (#5396) 2025-08-22 09:31:01 +02:00
Raphael Michel
fe9a7eaa24 Order overview: Try to make linked filters behave as expected for line-level cancellations (Z#23203500) 2025-08-22 09:30:34 +02:00
Luca Hammer
ebac7d563c Translations: Update German
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-08-22 09:16:19 +02:00
Raphael Michel
7ecc64ec73 Bump version to 2025.8.0.dev0 2025-08-20 13:04:14 +02:00
Raphael Michel
c9a806a7d0 Bump version to 2025.7.0 2025-08-20 13:03:45 +02:00
Raphael Michel
ab812a7d9c Translations: Update Italian
Currently translated at 35.3% (2145 of 6068 strings)

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

powered by weblate
2025-08-20 12:52:07 +02:00
Raphael Michel
500bca1323 Address form: Reduce useless XHR calls 2025-08-20 12:43:21 +02:00
Raphael Michel
32be6a159e Checkout: Hotfix data-trigger-address-info and company_required 2025-08-20 08:59:27 +02:00
Raphael Michel
0152d0c639 Hotfix crash PRETIXEU-C0E 2025-08-20 08:46:30 +02:00
Raphael Michel
e591c74862 Hotfix crash PRETIXEU-C0F 2025-08-20 08:44:33 +02:00
Raphael Michel
29de29fe96 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-08-19 19:45:52 +02:00
Raphael Michel
7bea17c70f Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-08-19 19:45:52 +02:00
Raphael Michel
f2b295e2a2 Translations: Update German
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-08-19 19:45:52 +02:00
Raphael Michel
64c7bc67bd Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-08-19 19:45:52 +02:00
Raphael Michel
c41a754ce6 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-08-19 19:45:52 +02:00
Raphael Michel
0bcb6b33bb Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-08-19 19:45:52 +02:00
Raphael Michel
1556226ff5 Translations: Update German
Currently translated at 100.0% (6068 of 6068 strings)

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

powered by weblate
2025-08-19 19:45:52 +02:00
Raphael Michel
4c022cb964 Translations: Update German
Currently translated at 99.9% (6066 of 6068 strings)

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

powered by weblate
2025-08-19 19:45:52 +02:00
Raphael Michel
8fb87fc489 Translations: Update wordlist 2025-08-19 19:08:33 +02:00
Raphael Michel
c8775fb21a Fix typo in wordlist 2025-08-19 18:37:49 +02:00
Raphael Michel
df0b322707 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@pretix.eu>
2025-08-19 18:36:10 +02:00
Raphael Michel
c200072471 Improve string quality and consistency 2025-08-19 18:35:35 +02:00
Raphael Michel
076233cba8 Translations: Update German
Currently translated at 100.0% (6070 of 6070 strings)

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

powered by weblate
2025-08-19 18:35:24 +02:00
Raphael Michel
b6efa9da7d Translations: Update German
Currently translated at 100.0% (6070 of 6070 strings)

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

powered by weblate
2025-08-19 18:35:24 +02:00
Raphael Michel
489636c335 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@pretix.eu>
2025-08-19 18:02:15 +02:00
Raphael Michel
cbee131378 Fix typos 2025-08-19 18:01:46 +02:00
Raphael Michel
05c74b7ad6 Pluggable invoice transmission methods (#5020)
* Flexible invoice transmission

* UI work

* Add peppol and output

* API support

* Profile integration

* Simplify form for individuals

* Remove sent_to_customer usage

* more steps

* Revert "Bank transfer: Allow to send the invoice direclty to the accounting department (#2975)"

This reverts commit cea6c340be.

* minor fixes

* Fixes after rebase

* update stati

* Backend view

* Transmit and show status

* status, retransmission

* API retransmission

* More fields

* API docs

* Plugin docs

* Update migration

* Add missing license headers

* Remove dead code, fix current tests

* Run isort

* Update regex

* Rebase migration

* Fix migration

* Add tests, fix bugs

* Rebase migration

* Apply suggestion from @luelista

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

* Apply suggestion from @luelista

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

* Apply suggestion from @luelista

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

* Apply suggestion from @luelista

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

* Apply suggestion from @luelista

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

* Make migration reversible

* Add TransmissionType.enforce_transmission

* Fix registries API usage after rebase

* Remove code I forgot to delete

* Update transmission status display depending on type

* Add testmode_supported

* Update src/pretix/static/pretixbase/js/addressform.js

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

* Update src/pretix/static/pretixbase/js/addressform.js

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

* Update src/pretix/static/pretixbase/js/addressform.js

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

* New mechanism for non-required invoice forms

* Update src/pretix/base/invoicing/transmission.py

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

* Declare testmode_supported for email

* Make transmission_email_other an implementation detail

* Fix failing tests and add new ones

* Update src/pretix/base/services/invoices.py

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

* Add emails to email history

* Fix comma error

* More generic default email text

* Cleanup

* Remove "email invoices" button and refine logic

* Rebase migration

* Fix edge case

---------

Co-authored-by: luelista <weller@rami.io>
2025-08-19 17:59:45 +02:00
Raphael Michel
37910f6037 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@pretix.eu>
2025-08-19 14:49:36 +02:00
Raphael Michel
0cc8e59bb0 Webhooks: Add vouchers (Z#23203072) (#5360)
* Webhooks: Add vouchers (Z#23203072)

This also requires more consistent usage of webhook types to avoid
vouchers not being known to the external system.

* Update src/pretix/api/webhooks.py

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

* Fix shredder test

---------

Co-authored-by: luelista <weller@rami.io>
2025-08-19 13:04:22 +02:00
Raphael Michel
7cdccc7d8e Bulk order-refund: Create log entries (Z#23203462) (#5357) 2025-08-19 12:09:23 +02:00
luelista
7e3f6df945 Document datasync_providers registry (#5387) 2025-08-19 12:01:26 +02:00
Raphael Michel
727ed67ff4 Orders: Prevent race condition in manual status transition (Z#23203887) (#5385) 2025-08-19 12:01:03 +02:00
Raphael Michel
a51a6123f5 Organizer-level plugins (#5305)
* Add version notes to the docs

* Adapt signal handling

* Add UI

* Add API

* API and tests

* Fix registry

* Update doc/development/api/plugins.rst

Co-authored-by: Felix Rindt <felix@rindt.me>

* Fix failing tests

* Apply suggestions from code review

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

* Update src/pretix/control/templates/pretixcontrol/organizers/plugin_events.html

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

* Update src/pretix/control/templates/pretixcontrol/organizers/plugins.html

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

* Update src/pretix/control/templates/pretixcontrol/organizers/plugins.html

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

* Update src/pretix/control/navigation.py

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

* Update src/pretix/control/urls.py

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

* Apply suggestion from @wiffbi

* REbase migration

* Fix review note

* Fix test cases

* Remove plugin from all events if disabled on org level

* Update doc/development/api/plugins.rst

* Unify registries

* Rebase migration

---------

Co-authored-by: Felix Rindt <felix@rindt.me>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: luelista <weller@rami.io>
2025-08-19 11:33:34 +02:00
luelista
56964b6764 Allow users to run sync jobs immediately (#5352)
* Allow users to run sync jobs immediately

* Transactional safety for manual handling of sync jobs (#5355)

* Fix indentation

---------

Co-authored-by: Raphael Michel <michel@rami.io>
2025-08-18 12:08:25 +02:00
luelista
527bc83e5f Add more sourcefields for datasync (#5378)
* add email domain field

* add order and ticket URL fields

* add "is admission product" field

* fix types

* Display sourcefields grouped into categories (#5379)
2025-08-18 12:07:50 +02:00
Raphael Michel
626d7ecc90 Upgrade to hierarkey 2.0 (#5373)
* Upgrade to hierarkey 2.0

* Fix duplicate setting of timezone

* Rebase migration
2025-08-18 11:41:57 +02:00
Luca Sorace "Stranck
69e50d35a7 API: Make case insensitive boolean query params (#5382) 2025-08-18 11:17:05 +02:00
dependabot[bot]
32b704de70 Update sentry-sdk requirement from ==2.34.* to ==2.35.*
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.34.0...2.35.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.35.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-18 11:16:18 +02:00
dependabot[bot]
1da00f575a Update protobuf requirement from ==6.31.* to ==6.32.*
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/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-18 11:16:04 +02:00
Yasunobu YesNo Kawaguchi
b7d01e3b28 Translations: Update Japanese
Currently translated at 100.0% (5941 of 5941 strings)

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

powered by weblate
2025-08-18 11:15:59 +02:00
Raphael Michel
650b4b461f Voucher: Add creation date (Z#23202621) (#5359)
* Voucher: Add creation date (Z#23202621)

* Migration fix

* Update doc/api/resources/vouchers.rst

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

* Update src/pretix/base/migrations/0285_voucher_created.py

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

---------

Co-authored-by: luelista <weller@rami.io>
2025-08-18 10:56:53 +02:00
Luca Sorace "Stranck
d14f7fb108 Orders API: Add check_quotas to orders/change and PATCH/POST orderpositions query params (#5323)
* Orders API: Add check_quotas to orders/change and PATCH/POST orderpositions query params

* Refs #5323: Checkstyle fix

Forgot tu run fkale8 after implementing unit tests oops

* Refs #5323: Fix unit tests and fix of the previous ones

* Refs #5323: PR review
2025-08-13 16:15:05 +02:00
Richard Schreiber
160f1c2e62 [A11y] Remove skip-to-main fallback container creation (#5372) 2025-08-13 12:24:49 +02:00
Raphael Michel
b9e627a86c PDF: Add font fallback on a pragraph level (Z#23203886) (#5367)
* PDF: Add font fallback on a pragraph level (Z#23203886)

* Fix empty texts
2025-08-13 10:51:23 +02:00
dependabot[bot]
328867c089 Update redis requirement from ==6.3.* to ==6.4.* (#5353)
Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v6.3.0...v6.4.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 6.4.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-13 09:13:41 +02:00
dependabot[bot]
3e45274343 Update fakeredis requirement from ==2.30.* to ==2.31.* (#5370)
Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.30.0...v2.31.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.31.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-13 08:31:31 +02:00
dependabot[bot]
538ca9f0c2 Update pypdf requirement from ==5.9.* to ==6.0.* (#5371)
Updates the requirements on [pypdf](https://github.com/py-pdf/pypdf) to permit the latest version.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/5.9.0...6.0.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-13 08:31:11 +02:00
Raphael Michel
99e10adad4 Revert "PDF: Add font fallback on a pragraph level (Z#23203886)"
This reverts commit 10b5f76356.
2025-08-12 15:51:43 +02:00
Raphael Michel
10b5f76356 PDF: Add font fallback on a pragraph level (Z#23203886) 2025-08-12 15:51:13 +02:00
Raphael Michel
39a0093c6b Fix subtotal rendering on mobile (#5365) 2025-08-12 09:39:21 +02:00
Richard Schreiber
d8bf3d0b07 Fix select2 config typo (#5363) 2025-08-11 14:30:25 +02:00
Yasunobu YesNo Kawaguchi
4e56ce8927 Translations: Update Japanese
Currently translated at 99.1% (5891 of 5941 strings)

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

powered by weblate
2025-08-08 16:12:05 +02:00
Raphael Michel
807df01f5d Checkout: Delete invoice address if no longer required (Z#23203488) (#5358) 2025-08-08 15:56:35 +02:00
Raphael Michel
067e11c265 Allow to annul a check-in (#5303)
* Allow to annul a check-in

* Fix locking

* Update doc/api/resources/checkin.rst

Co-authored-by: Phin Wolkwitz <wolkwitz@rami.io>

---------

Co-authored-by: Phin Wolkwitz <wolkwitz@rami.io>
2025-08-08 09:22:19 +02:00
Mira Weller
b4264c0ae7 Fix deletion of inactive queue items (PRETIXEU-BZ0) 2025-08-07 13:15:55 +02:00
luelista
61eff28978 Use deserialized data structures for mapping configuration (#5351) 2025-08-07 12:19:15 +02:00
luelista
4e89772c2d Normalize IDN email addresses (#5350) 2025-08-07 09:44:15 +02:00
Raphael Michel
3212dd9b40 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5941 of 5941 strings)

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

powered by weblate
2025-08-07 09:44:06 +02:00
Raphael Michel
97c1fb9101 Translations: Update German
Currently translated at 100.0% (5941 of 5941 strings)

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

powered by weblate
2025-08-07 09:44:06 +02:00
luelista
d5bccf8726 Queueing and mapping utilities for outbound data sync (#4881)
Add a registry for datasync providers and an associated sync queue, to be used by 
plugins that transfer data from pretix orders to external systems. 
Additionally, provide a generic data mapping interface to be used in settings pages 
of such plugins, to let users configure which information from pretix to fill into
which data fields of the external system.

---------

Co-authored-by: Raphael Michel <michel@rami.io>
2025-08-06 14:34:04 +02:00
Ryo Tagami
d768c46fa1 Translations: Update Japanese
Currently translated at 99.1% (5891 of 5941 strings)

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

powered by weblate
2025-08-06 11:46:54 +02:00
dependabot[bot]
5a506bfbd6 Update redis requirement from ==6.2.* to ==6.3.*
Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v6.2.0...v6.3.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 6.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-06 11:46:50 +02:00
Raphael Michel
3508d22591 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5941 of 5941 strings)

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

powered by weblate
2025-08-05 10:07:25 +02:00
Raphael Michel
4a6dd12884 Translations: Update German
Currently translated at 100.0% (5941 of 5941 strings)

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

powered by weblate
2025-08-05 10:07:25 +02:00
Raphael Michel
60b906d8b7 Translations: Update wordlist 2025-08-05 10:04:47 +02:00
Luca Sorace "Stranck
4285612162 OrderPayment.fail: Change race condition detection condition (#5320) 2025-08-05 09:57:14 +02:00
Raphael Michel
a3b1e4d208 OIDC client: Add more logging 2025-08-05 09:48:16 +02:00
Raphael Michel
3a6d7b8e92 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-08-05 09:45:05 +02:00
dependabot[bot]
a5d01aa2d1 Bump @babel/preset-env in /src/pretix/static/npm_dir (#5339)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.27.2 to 7.28.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.0/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-version: 7.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 09:29:15 +02:00
Raphael Michel
89d8ca0fc2 Add two more country translations (Z#23166278) 2025-08-05 09:28:34 +02:00
Raphael Michel
34b656989f Fix select2 with allowClear and no placeholder (Z#23203145) 2025-08-04 17:22:35 +02:00
Tobias Kunze
154f10af8f Fix bulk voucher CSV field description (#5120) 2025-08-04 16:35:19 +02:00
Raphael Michel
782d659c59 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5939 of 5939 strings)

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

powered by weblate
2025-08-04 16:19:29 +02:00
Raphael Michel
1b4308e101 Translations: Update German
Currently translated at 100.0% (5939 of 5939 strings)

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

powered by weblate
2025-08-04 16:19:29 +02:00
Raphael Michel
9a119c35a8 Add a system-wide style for admin-only things (#5311)
* Add a system-wide style for admin-only things

* change stripe-color to a red-ish tone

* add stripes to button end-admin-session

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-08-04 16:18:29 +02:00
Raphael Michel
a8ac1b1a94 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-08-04 16:08:12 +02:00
Waldir Pimenta
6338dceb9e Clarify checkout success message (#5336) 2025-08-04 16:07:45 +02:00
Raphael Michel
e4a171c11f Translations: Update German (informal) (de_Informal)
Currently translated at 99.9% (5937 of 5939 strings)

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

powered by weblate
2025-08-04 16:03:40 +02:00
Raphael Michel
e9edcfdfdc Translations: Update German
Currently translated at 100.0% (5939 of 5939 strings)

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

powered by weblate
2025-08-04 16:03:40 +02:00
Raphael Michel
ef3ff52be3 Translations: Update wordlist 2025-08-04 15:57:27 +02:00
Martin Gross
a8f74d87ec Sendmail: Fix selector for pending/overdue for scheduled messages (Z#23202906) (#5338)
* Sendmail: Fix selector for pending/overdue for scheduled messages (Z#287303)

* isort

* Apply suggestions from code review

---------

Co-authored-by: Raphael Michel <michel@rami.io>
2025-08-04 15:45:17 +02:00
Richard Schreiber
6f920e6bcd Add event's location to order confirmation mail (Z#23185285) (#5341)
* Add event's location to order confirmation mail

* make location oneline
2025-08-04 15:02:53 +02:00
Raphael Michel
a6201c841f Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-08-04 14:45:01 +02:00
dependabot[bot]
b5ac28e36c Update css-inline requirement from ==0.16.* to ==0.17.*
Updates the requirements on [css-inline](https://github.com/Stranger6667/css-inline) to permit the latest version.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: css-inline
  dependency-version: 0.17.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-04 14:44:17 +02:00
dependabot[bot]
bf5e1aeaff Update pypdf requirement from ==5.8.* to ==5.9.*
Updates the requirements on [pypdf](https://github.com/py-pdf/pypdf) to permit the latest version.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/5.8.0...5.9.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 5.9.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-04 14:44:00 +02:00
dependabot[bot]
3f6d230c01 Update sentry-sdk requirement from ==2.31.* to ==2.34.*
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.31.0...2.34.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.34.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-04 14:43:50 +02:00
dependabot[bot]
a4aa3cbd3b Bump django-bootstrap3 from 25.1 to 25.2 (#5337) 2025-08-04 14:42:40 +02:00
Yasunobu YesNo Kawaguchi
8ee90cd1c4 Translations: Update Japanese
Currently translated at 100.0% (5909 of 5909 strings)

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

powered by weblate
2025-08-04 14:42:09 +02:00
Ryo Tagami
8d1e679a84 Translations: Update Japanese
Currently translated at 100.0% (5909 of 5909 strings)

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

powered by weblate
2025-08-04 14:42:09 +02:00
Hijiri Umemoto
87f829f4d2 Translations: Update Chinese (Traditional Han script)
Currently translated at 96.0% (5677 of 5909 strings)

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

powered by weblate
2025-08-04 14:42:09 +02:00
Hijiri Umemoto
75dcb920a7 Translations: Update Japanese
Currently translated at 97.6% (246 of 252 strings)

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

powered by weblate
2025-08-04 14:42:09 +02:00
Yasunobu YesNo Kawaguchi
e68f0a7402 Translations: Update Japanese
Currently translated at 100.0% (5909 of 5909 strings)

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

powered by weblate
2025-08-04 14:42:09 +02:00
Hijiri Umemoto
4255dbfb83 Translations: Update Japanese
Currently translated at 100.0% (5909 of 5909 strings)

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

powered by weblate
2025-08-04 14:42:09 +02:00
Hijiri Umemoto
9def5cc7b2 Translations: Update Japanese
Currently translated at 99.1% (5857 of 5909 strings)

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

powered by weblate
2025-08-04 14:42:09 +02:00
Nikolai
17a467887c Translations: Update Danish
Currently translated at 47.5% (2810 of 5909 strings)

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

powered by weblate
2025-08-04 14:42:09 +02:00
dependabot[bot]
0736babf3c Bump @babel/core from 7.27.7 to 7.28.0 in /src/pretix/static/npm_dir
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.27.7 to 7.28.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.0/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-04 14:40:33 +02:00
Raphael Michel
a5b773924c Hotfix for country names (Z#23166278) 2025-08-04 14:39:57 +02:00
Raphael Michel
391918afe7 Add missing djangojs.po for es_419 2025-08-04 14:39:57 +02:00
Martin Gross
d8f9f9478d Exporter/orderlist: Add link to position order page (Z#23202597) 2025-07-31 12:39:06 +02:00
Richard Schreiber
4d9f1a8efc [A11y] add main-landmark to all presale pages (#5332) 2025-07-30 14:30:45 +02:00
Richard Schreiber
23b07e29cd [A11y] Presale: improve heading levels for better document outline (#5335) 2025-07-30 11:51:19 +02:00
303 changed files with 263322 additions and 196529 deletions

View File

@@ -359,3 +359,65 @@ Performing a ticket search
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or check-in list does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested check-in list does not exist.
.. _`rest-checkin-annul`:
Annulment of a check-in
-----------------------
.. http:post:: /api/v1/organizers/(organizer)/checkinrpc/annul/
If a check-in was made in error and the person was not let in, it can be annulled. We do not recommend this to be used
in case of manual check-ins or user interfaces because it is too prone for human errors. It is mostly intended for
automated entry systems like a turnstile or automated door, where the check-in is first created, then the door is
opened, and then the check-in may be annulled if the system knows that the turnstile did not turn or was out of
order.
This endpoint supports passing multiple check-in lists for the context of a multi-event scan. However, each
check-in list passed needs to be from a distinct event.
Check-ins created by a device can only be annulled by the same device. The datetime of annulment may not be more than
15 minutes after the datetime of check-in (value subject to change).
A status code of 404 is returned if no check-in was found for the given nonce. A status code of 400 is returned when
multiple check-ins match the nonce, the input is invalid in another way, the annulment is made from the wrong device,
the check-in is already in an annulled or failed state, or the datetime constraint is not valid.
:<json string nonce: ``nonce`` value of the original check-in.
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
:<json datetime datetime: Specifies the client-side datetime of the annulment. If not supplied, the current time will be used.
:<json string error_explanation: A human-readable description of why the check-in was annulled (optional).
:>json string status: ``"ok"``
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/checkinrpc/annul/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
{
"lists": [1],
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
"error_explanation": "Turnstile did not turn"
}
**Example successful response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"status": "ok",
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 400: Invalid or incomplete request, see above
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested nonce does not exist.

View File

@@ -424,9 +424,9 @@ Endpoints
:param organizer: The ``slug`` field of the organizer of the event to create.
:param event: The ``slug`` field of the event to copy settings and items from.
:statuscode 201: no error
:statuscode 400: The event could not be created due to invalid submitted data.
:statuscode 400: The event could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
:statuscode 403: The requested organizer does not exist **or** you have no permission to update this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/

View File

@@ -26,6 +26,8 @@ invoice_from_country string Sender address:
invoice_from_tax_id string Sender address: Local Tax ID
invoice_from_vat_id string Sender address: EU VAT ID
invoice_to string Full recipient address
invoice_to_is_business boolean Recipient address: Business vs individual (``null`` for
invoices created before pretix 2025.6).
invoice_to_company string Recipient address: Company name
invoice_to_name string Recipient address: Person name
invoice_to_street string Recipient address: Address lines
@@ -35,6 +37,7 @@ invoice_to_state string Recipient addre
invoice_to_country string Recipient address: Country code
invoice_to_vat_id string Recipient address: EU VAT ID
invoice_to_beneficiary string Invoice beneficiary
invoice_to_transmission_info object Additional transmission info (see :ref:`rest-transmission-types`)
custom_field string Custom invoice address field
date date Invoice date
refers string Invoice number of an invoice this invoice refers to
@@ -110,6 +113,12 @@ foreign_currency_rate decimal (string) If ``foreign_cu
foreign_currency_rate_date date If ``foreign_currency_rate`` is set, this signifies the
date at which the currency rate was obtained.
internal_reference string Customer's reference to be printed on the invoice.
transmission_type string Requested transmission channel (see :ref:`rest-transmission-types`)
transmission_provider string Selected transmission provider (depends on installed
plugins). ``null`` if not yet chosen.
transmission_status string Transmission status, one of ``unknown`` (pre-2025.6),
``pending``, ``inflight``, ``failed``, and ``completed``.
transmission_date datetime Time of last change in transmission status (may be ``null``).
===================================== ========================== =======================================================
@@ -121,6 +130,76 @@ internal_reference string Customer's refe
The ``tax_code`` attribute has been added.
.. versionchanged:: 2025.6
The attributes ``invoice_to_is_business``, ``invoice_to_transmission_info``, ``transmission_type``,
``transmission_provider``, ``transmission_status``, and ``transmission_date`` have been added.
.. _`rest-transmission-types`:
Transmission types
------------------
pretix supports multiple ways to transmit an invoice from the organizer to the invoice recipient.
For each transmission type, different fields are supported in the ``transmission_info`` object of the
invoice address. Currently, pretix supports the following transmission types:
Email
"""""
The identifier ``"email"`` represents the transmission of PDF invoices through email.
This is the default transmission type in pretix and has some special behavior for backwards compatibility.
Transmission is always executed through the provider ``"email_pdf"``.
The ``transmission_info`` object may contain the following properties:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
transmission_email_address string Optional. An email address other than the order address
that the invoice should be sent to.
Business customers only.
===================================== ========================== =======================================================
PEPPOL
""""""
The identifier ``"peppol"`` represents the transmission of XML invoices through the `PEPPOL`_ network.
This is only available for business addresses.
This is not supported by pretix out of the box and requires the use of a suitable plugin.
The ``transmission_info`` object may contain the following properties:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
transmission_peppol_participant_id string Required. The PEPPOL participant ID of the recipient.
===================================== ========================== =======================================================
Italian Exchange System
"""""""""""""""""""""""
The identifier ``"it_sdi"`` represents the transmission of XML invoices through the `Sistema di Interscambio`_ network used in Italy.
This is only available for addresses with country ``"IT"``.
This is not supported by pretix out of the box and requires the use of a suitable plugin.
The ``transmission_info`` object may contain the following properties:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
transmission_it_sdi_codice_fiscale string Required for non-business address. Fiscal code of the
recipient.
transmission_it_sdi_pec string Required for business addresses. Address for certified
electronic mail.
transmission_it_sdi_recipient_code string Required for businesses. SdI recipient code.
===================================== ========================== =======================================================
If this type is selected, ``vat_id`` is required for business addresses.
List of all invoices
--------------------
@@ -164,6 +243,7 @@ List of all invoices
"invoice_from_vat_id":"",
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789",
"invoice_to_company": "Sample company",
"invoice_to_is_business": true,
"invoice_to_name": "John Doe",
"invoice_to_street": "Test street 12",
"invoice_to_zipcode": "12345",
@@ -172,6 +252,7 @@ List of all invoices
"invoice_to_country": "TE",
"invoice_to_vat_id": "EU123456789",
"invoice_to_beneficiary": "",
"invoice_to_transmission_info": {},
"custom_field": null,
"date": "2017-12-01",
"refers": null,
@@ -204,7 +285,11 @@ List of all invoices
],
"foreign_currency_display": "PLN",
"foreign_currency_rate": "4.2408",
"foreign_currency_rate_date": "2017-07-24"
"foreign_currency_rate_date": "2017-07-24",
"transmission_type": "email",
"transmission_provider": "email_pdf",
"transmission_status": "completed",
"transmission_date": "2017-07-24T10:00:00Z"
}
]
}
@@ -304,6 +389,7 @@ Fetching individual invoices
"invoice_from_vat_id":"",
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789",
"invoice_to_company": "Sample company",
"invoice_to_is_business": true,
"invoice_to_name": "John Doe",
"invoice_to_street": "Test street 12",
"invoice_to_zipcode": "12345",
@@ -312,6 +398,7 @@ Fetching individual invoices
"invoice_to_country": "TE",
"invoice_to_vat_id": "EU123456789",
"invoice_to_beneficiary": "",
"invoice_to_transmission_info": {},
"custom_field": null,
"date": "2017-12-01",
"refers": null,
@@ -344,7 +431,11 @@ Fetching individual invoices
],
"foreign_currency_display": "PLN",
"foreign_currency_rate": "4.2408",
"foreign_currency_rate_date": "2017-07-24"
"foreign_currency_rate_date": "2017-07-24",
"transmission_type": "email",
"transmission_provider": "email_pdf",
"transmission_status": "completed",
"transmission_date": "2017-07-24T10:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -449,3 +540,70 @@ Invoices cannot be edited directly, but the following actions can be triggered:
:statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
Transmitting invoices
---------------------
Invoices are transmitted automatically when created during order creation or payment receipt,
but in other cases transmission may need to be triggered manually.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/transmit/
Transmits the invoice to the recipient, but only if it is in ``pending`` state.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/transmit/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/pdf
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to transmit
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to transmit this invoice **or** the invoice may not be transmitted
:statuscode 409: The invoice is currently in transmission
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/retransmit/
Transmits the invoice to the recipient even if transmission was already attempted previously.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/retransmit/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/pdf
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to transmit
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to transmit this invoice **or** the invoice may not be transmitted
:statuscode 409: The invoice is currently in transmission
.. _PEPPOL: https://en.wikipedia.org/wiki/PEPPOL
.. _Sistema di Interscambio: https://it.wikipedia.org/wiki/Fattura_elettronica_in_Italia

View File

@@ -65,11 +65,16 @@ invoice_address object Invoice address
├ state string Customer state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US.
├ internal_reference string Customer's internal reference to be printed on the invoice
├ custom_field string Custom invoice address field
├ vat_id string Customer VAT ID
vat_id_validated string ``true``, if the VAT ID has been validated against the
vat_id_validated string ``true``, if the VAT ID has been validated against the
EU VAT service and validation was successful. This only
happens in rare cases.
├ transmission_type string Transmission channel for invoice (see also :ref:`rest-transmission-types`).
Defaults to ``email``.
└ transmission_info object Transmission-channel specific information (or ``null``).
See also :ref:`rest-transmission-types`.
positions list of objects List of order positions (see below). By default, only
non-canceled positions are included.
fees list of objects List of fees included in the order total. By default, only
@@ -142,6 +147,10 @@ plugin_data object Additional data
The ``plugin_data`` attribute has been added.
.. versionchanged:: 2025.6
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
.. _order-position-resource:
Order position resource
@@ -368,7 +377,9 @@ List of all orders
"state": "",
"internal_reference": "",
"vat_id": "EU123456789",
"vat_id_validated": false
"vat_id_validated": false,
"transmission_type": "email",
"transmission_info": {}
},
"positions": [
{
@@ -610,7 +621,9 @@ Fetching individual orders
"state": "",
"internal_reference": "",
"vat_id": "EU123456789",
"vat_id_validated": false
"vat_id_validated": false,
"transmission_type": "email",
"transmission_info": {}
},
"positions": [
{
@@ -1017,6 +1030,8 @@ Creating orders
* ``vat_id_validated`` (optional) If you need support for reverse charge (rarely the case), you need to check
yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will
trigger reverse charge taxation. Don't forget to set ``is_business`` as well!
* ``transmission_type`` (optional, defaults to ``email``)
* ``transmission_info`` (optional, see also :ref:`rest-transmission-types`)
* ``positions``
@@ -1926,6 +1941,7 @@ Manipulating individual positions
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before committing item changes, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param id: The ``id`` field of the order position to update
@@ -2005,6 +2021,7 @@ Manipulating individual positions
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before creating the new position, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
@@ -2291,6 +2308,7 @@ otherwise, such as splitting an order or changing fees.
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before patching or creating positions, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``code`` field of the order to update

View File

@@ -19,6 +19,11 @@ name string The organizer's
slug string A short form of the name, used e.g. in URLs.
public_url string The public, customer-facing URL of the organizer, where
the list of all events can be found (read-only).
plugins list A list of package names of the enabled plugins for this
organizer. Note that most plugins are enabled on the
event level (or both levels). If you remove a plugin
that is also enabled on some events, it will
automatically be removed from all events as well.
===================================== ========================== =======================================================
@@ -53,7 +58,10 @@ Endpoints
{
"name": "Big Events LLC",
"slug": "Big Events",
"public_url": "https://pretix.eu/bigevents/"
"public_url": "https://pretix.eu/bigevents/",
"plugins": [
"pretix_datev"
]
}
]
}
@@ -87,7 +95,10 @@ Endpoints
{
"name": "Big Events LLC",
"slug": "Big Events",
"public_url": "https://pretix.eu/bigevents/"
"public_url": "https://pretix.eu/bigevents/",
"plugins": [
"pretix_datev"
]
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -95,6 +106,50 @@ Endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:patch:: /api/v1/organizers/(organizer)/
Updates an organizer. Currently only the ``plugins`` field may be updated.
Permission required: "Can change organizer settings"
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"plugins": [
"pretix_seating"
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"name": "Big Events LLC",
"slug": "Big Events",
"public_url": "https://pretix.eu/bigevents/",
"plugins": [
"pretix_seating"
]
}
:param organizer: The ``slug`` field of the organizer to update
:statuscode 200: no error
:statuscode 400: The organizer could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to update this resource.
Organizer settings
------------------

View File

@@ -38,6 +38,10 @@ available_number integer Number of avail
slightly out of date. ``null`` means unlimited.
===================================== ========================== =======================================================
.. versionchanged:: 2025.7
The attribute ``ignore_for_event_availability`` has been added.
Endpoints
---------

View File

@@ -14,6 +14,7 @@ The voucher resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the voucher
created datetime The creation date of the voucher. For vouchers created before pretix 2025.7.0, this is guessed retroactively and might not be accurate.
code string The voucher code that is required to redeem the voucher
max_usages integer The maximum number of times this voucher can be
redeemed (default: 1).
@@ -53,6 +54,10 @@ budget money (string) The budget a vo
budget_used money (string) The amount of budget the voucher has already used up.
===================================== ========================== =======================================================
.. versionchanged:: 2025.7
The attributes ``created``, ``budget``, and ``budget_used`` have been added.
Endpoints
---------
@@ -84,6 +89,7 @@ Endpoints
"results": [
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -156,6 +162,7 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -228,6 +235,7 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -321,6 +329,7 @@ Endpoints
[
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
}, …
@@ -367,6 +376,7 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,

View File

@@ -60,6 +60,9 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.added``
* ``pretix.event.changed``
* ``pretix.event.deleted``
* ``pretix.voucher.added``
* ``pretix.voucher.changed``
* ``pretix.voucher.deleted``
* ``pretix.subevent.added``
* ``pretix.subevent.changed``
* ``pretix.subevent.deleted``

View File

@@ -0,0 +1,207 @@
.. highlight:: python
:linenothreshold: 5
Data sync providers
===================
.. warning:: This feature is considered **experimental**. It might change at any time without prior notice.
pretix provides connectivity to many external services through plugins. A common requirement
is unidirectionally sending (order, customer, ticket, ...) data into external systems.
The transfer is usually triggered by signals provided by pretix core (e.g. :data:`order_placed`),
but performed asynchronously.
Such plugins should use the :class:`OutboundSyncProvider` API to utilize the queueing, retry and mapping
mechanisms as well as the user interface for configuration and monitoring. Sync providers are registered
in the :py:attr:`pretix.base.datasync.datasync.datasync_providers` :ref:`registry <registries>`.
An :class:`OutboundSyncProvider` for subscribing event participants to a mailing list could start
like this, for example:
.. code-block:: python
from pretix.base.datasync.datasync import (OutboundSyncProvider, datasync_providers)
@datasync_providers.register
class MyListSyncProvider(OutboundSyncProvider):
identifier = "my_list"
display_name = "My Mailing List Service"
# ...
The plugin must register listeners in `signals.py` for all signals that should to trigger a sync and
within it has to call :meth:`MyListSyncProvider.enqueue_order` to enqueue the order for synchronization:
.. code-block:: python
@receiver(order_placed, dispatch_uid="mylist_order_placed")
def on_order_placed(sender, order, **kwargs):
MyListSyncProvider.enqueue_order(order, "order_placed")
Property mappings
-----------------
Most of these plugins need to translate data from some pretix objects (e.g. orders)
into an external system's data structures. Sometimes, there is only one reasonable way or the
plugin author makes an opinionated decision what information from which objects should be
transferred into which data structures in the external system.
Otherwise, you can use a :class:`PropertyMappingFormSet` to let the user set up a mapping from pretix model fields
to external data fields. You could store the mapping information either in the event settings, or in a separate
data model. Your implementation of :attr:`OutboundSyncProvider.mappings`
needs to provide a list of mappings, which can be e.g. static objects or model instances, as long as they
have at least the properties defined in
:class:`pretix.base.datasync.datasync.StaticMapping`.
.. code-block:: python
# class MyListSyncProvider, contd.
def mappings(self):
return [
StaticMapping(
id=1, pretix_model='Order', external_object_type='Contact',
pretix_id_field='email', external_id_field='email',
property_mappings=self.event.settings.mylist_order_mapping,
))
]
Currently, we support `orders` and `order positions` as data sources, with the data fields defined in
:func:`pretix.base.datasync.sourcefields.get_data_fields`.
To perform the actual sync, implement :func:`sync_object_with_properties` and optionally
:func:`finalize_sync_order`. The former is called for each object to be created according to the ``mappings``.
For each order that was enqueued using :func:`enqueue_order`:
- each Mapping with ``pretix_model == "Order"`` results in one call to :func:`sync_object_with_properties`,
- each Mapping with ``pretix_model == "OrderPosition"`` results in one call to
:func:`sync_object_with_properties` per order position,
- :func:`finalize_sync_order` is called one time after all calls to :func:`sync_object_with_properties`.
Implementation examples
-----------------------
For example implementations, see the test cases in :mod:`tests.base.test_datasync`.
In :class:`SimpleOrderSync`, a basic data transfer of order data only is
shown. Therein, a ``sync_object_with_properties`` method is defined as follows:
.. code-block:: python
from pretix.base.datasync.utils import assign_properties
# class MyListSyncProvider, contd.
def sync_object_with_properties(
self, external_id_field, id_value, properties: list, inputs: dict,
mapping, mapped_objects: dict, **kwargs,
):
# First, we query the external service if our object-to-sync already exists there.
# This is necessary to make sure our method is idempotent, i.e. handles already synced
# data gracefully.
pre_existing_object = self.fake_api_client.retrieve_object(
mapping.external_object_type,
external_id_field,
id_value
)
# We use the helper function ``assign_properties`` to update a pre-existing object.
update_values = assign_properties(
new_values=properties,
old_values=pre_existing_object or {},
is_new=pre_existing_object is None,
list_sep=";",
)
# Then we can send our new data to the external service. The specifics of course depends
# on your API, e.g. you may need to use different endpoints for creating or updating an
# object, or pass the identifier separately instead of in the same dictionary as the
# other properties.
result = self.fake_api_client.create_or_update_object(mapping.external_object_type, {
**update_values,
external_id_field: id_value,
"_id": pre_existing_object and pre_existing_object.get("_id"),
})
# Finally, return a dictionary containing at least `object_type`, `external_id_field`,
# `id_value`, `external_link_href`, and `external_link_display_name` keys.
# Further keys may be provided for your internal use. This dictionary is provided
# in following calls in the ``mapped_objects`` dict, to allow creating associations
# to this object.
return {
"object_type": mapping.external_object_type,
"external_id_field": external_id_field,
"id_value": id_value,
"external_link_href": f"https://example.org/external-system/{mapping.external_object_type}/{id_value}/",
"external_link_display_name": f"Contact #{id_value} - Jane Doe",
"my_result": result,
}
.. note:: The result dictionaries of earlier invocations of :func:`sync_object_with_properties` are
only provided in subsequent calls of the same sync run, such that a mapping can
refer to e.g. the external id of an object created by a preceding mapping.
However, the result dictionaries are currently not provided across runs. This will
likely change in a future revision of this API, to allow easier integration of external
systems that do not allow retrieving/updating data by a pretix-provided key.
``mapped_objects`` is a dictionary of lists of dictionaries. The keys to the dictionary are
the mapping identifiers (``mapping.id``), the lists contain the result dictionaries returned
by :func:`sync_object_with_properties`.
In :class:`OrderAndTicketAssociationSync`, an example is given where orders, order positions,
and the association between them are transferred.
The OutboundSyncProvider base class
-----------------------------------
.. autoclass:: pretix.base.datasync.datasync.OutboundSyncProvider
:members:
Property mapping format
-----------------------
To allow the user to configure property mappings, you can use the PropertyMappingFormSet,
which will generate the required ``property_mappings`` value automatically. If you need
to specify the property mappings programmatically, you can refer to the description below
on their format.
.. autoclass:: pretix.control.forms.mapping.PropertyMappingFormSet
:members: to_property_mappings_json
A simple JSON-serialized ``property_mappings`` list for mapping some order information can look like this:
.. code-block:: json
[
{
"pretix_field": "email",
"external_field": "orderemail",
"value_map": "",
"overwrite": "overwrite",
},
{
"pretix_field": "order_status",
"external_field": "status",
"value_map": "{\"n\": \"pending\", \"p\": \"paid\", \"e\": \"expired\", \"c\": \"canceled\", \"r\": \"refunded\"}",
"overwrite": "overwrite",
},
{
"pretix_field": "order_total",
"external_field": "total",
"value_map": "",
"overwrite": "overwrite",
}
]
Translating mappings on Event copy
----------------------------------
Property mappings can contain references to event-specific primary keys. Therefore, plugins must register to the
event_copy_data signal and call translate_property_mappings on all property mappings they store.
.. autofunction:: pretix.base.datasync.utils.translate_property_mappings

View File

@@ -30,7 +30,7 @@ Check-ins
.. automodule:: pretix.base.signals
:no-index:
:members: checkin_created
:members: checkin_created, checkin_annulled
Frontend

View File

@@ -13,10 +13,12 @@ Contents:
email
placeholder
invoice
invoicetransmission
shredder
import
customview
cookieconsent
auth
datasync
general
quality

View File

@@ -0,0 +1,65 @@
.. highlight:: python
:linenothreshold: 5
Writing an invoice transmission plugin
======================================
An invoice transmission provider transports an invoice from the sender to the recipient.
There are pre-defined types of invoice transmission in pretix, currently ``"email"``, ``"peppol"``, and ``"it_sdi"``.
You can find more information about them at :ref:`rest-transmission-types`.
New transmission types can not be added by plugins but need to be added to pretix itself.
However, plugins can provide implementations for the actual transmission.
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
Output registration
-------------------
New invoice transmission providers can be registered through the :ref:`registry <registries>` mechanism
.. code-block:: python
from pretix.base.invoicing.transmission import transmission_providers, TransmissionProvider
@transmission_providers.new()
class SdiTransmissionProvider(TransmissionProvider):
identifier = "fatturapa_providerabc"
type = "it_sdi"
verbose_name = _("FatturaPA through provider ABC")
...
The provider class
------------------
.. class:: pretix.base.invoicing.transmission.TransmissionProvider
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: type
This is an abstract attribute, you **must** override this!
.. autoattribute:: verbose_name
This is an abstract attribute, you **must** override this!
.. autoattribute:: priority
.. autoattribute:: testmode_supported
.. automethod:: is_ready
This is an abstract method, you **must** override this!
.. automethod:: is_available
This is an abstract method, you **must** override this!
.. automethod:: transmit
This is an abstract method, you **must** override this!
.. automethod:: settings_url

View File

@@ -56,6 +56,20 @@ restricted boolean (optional) ``False`` by default, restricts a plugin
for an event by system administrators / superusers.
experimental boolean (optional) ``False`` by default, marks a plugin as an experimental feature in the plugins list.
compatibility string Specifier for compatible pretix versions.
level string System level the plugin can be activated at.
Set to ``pretix.base.plugins.PLUGIN_LEVEL_EVENT`` for plugins that can be activated
at event level and then be active for that event only.
Set to ``pretix.base.plugins.PLUGIN_LEVEL_ORGANIZER`` for plugins that can be
activated only for the organizer as a whole and are active for any event within
that organizer.
Set to ``pretix.base.plugins.PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID`` for plugins that
can be activated at organizer level but are considered active only within events
for which they have also been specifically activated.
More levels, e.g. user-level plugins, might be invented in the future.
settings_links list List of ``((menu name, submenu name, …), urlname, url_kwargs)`` tuples that point
to the plugin's settings.
navigation_links list List of ``((menu name, submenu name, …), urlname, url_kwargs)`` tuples that point
to the plugin's system pages.
================== ==================== ===========================================================
A working example would be:
@@ -63,9 +77,9 @@ A working example would be:
.. code-block:: python
try:
from pretix.base.plugins import PluginConfig
from pretix.base.plugins import PluginConfig, PLUGIN_LEVEL_EVENT
except ImportError:
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
raise RuntimeError("Please use pretix 2025.7 or above to run this plugin!")
from django.utils.translation import gettext_lazy as _
@@ -79,6 +93,7 @@ A working example would be:
version = '1.0.0'
category = 'PAYMENT'
picture = 'pretix_paypal/paypal_logo.svg'
level = PLUGIN_LEVEL_EVENT
visible = True
featured = False
restricted = False
@@ -142,14 +157,14 @@ method to make your receivers available:
from . import signals # NOQA
You can optionally specify code that is executed when your plugin is activated for an event
in the ``installed`` method:
or organizer in the ``installed`` method:
.. code-block:: python
class PaypalApp(AppConfig):
def installed(self, event):
def installed(self, event_or_organizer):
pass # Your code here

View File

@@ -33,16 +33,16 @@ dependencies = [
"celery==5.5.*",
"chardet==5.2.*",
"cryptography>=44.0.0",
"css-inline==0.16.*",
"css-inline==0.17.*",
"defusedcsv>=1.1.0",
"Django[argon2]==4.2.*,>=4.2.15",
"django-bootstrap3==25.1",
"django-bootstrap3==25.2",
"django-compressor==4.5.1",
"django-countries==7.6.*",
"django-filter==25.1",
"django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1",
"django-hierarkey==1.2.*",
"django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*",
"django-i18nfield==1.10.*",
"django-libsass==0.9",
@@ -76,22 +76,22 @@ dependencies = [
"phonenumberslite==9.0.*",
"Pillow==11.3.*",
"pretix-plugin-build",
"protobuf==6.31.*",
"protobuf==6.32.*",
"psycopg2-binary",
"pycountry",
"pycparser==2.22",
"pycryptodome==3.23.*",
"pypdf==5.8.*",
"pypdf==6.0.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
"pytz-deprecation-shim==0.1.*",
"pyuca",
"qrcode==8.2",
"redis==6.2.*",
"redis==6.4.*",
"reportlab==4.4.*",
"requests==2.31.*",
"sentry-sdk==2.31.*",
"sentry-sdk==2.35.*",
"sepaxml==2.6.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -110,7 +110,7 @@ dev = [
"aiohttp==3.12.*",
"coverage",
"coveralls",
"fakeredis==2.30.*",
"fakeredis==2.31.*",
"flake8==7.3.*",
"freezegun",
"isort==6.0.*",

View File

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

View File

@@ -104,3 +104,14 @@ class MiniCheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class CheckinRPCAnnulInputSerializer(serializers.Serializer):
lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none())
nonce = serializers.CharField(required=True, allow_null=False)
datetime = serializers.DateTimeField(required=False, allow_null=True)
error_explanation = serializers.CharField(required=False, allow_null=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')

View File

@@ -50,6 +50,7 @@ from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import (
CompatibleJSONField, SalesChannelMigrationMixin,
)
from pretix.api.serializers.fields import PluginsField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.models import (
@@ -61,6 +62,9 @@ from pretix.base.models.items import (
ItemMetaProperty, SubEventItem, SubEventItemVariation,
)
from pretix.base.models.tax import CustomRulesValidator
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
)
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
@@ -126,22 +130,6 @@ class SeatCategoryMappingField(Field):
}
class PluginsField(Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
return sorted([
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
])
def to_internal_value(self, data):
return {
'plugins': data
}
class TimeZoneField(ChoiceField):
def get_attribute(self, instance):
return instance.cache.get_or_set(
@@ -283,17 +271,28 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module: p for p in get_all_plugins(self.instance)
p.module: p for p in get_all_plugins(event=self.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
current_plugins = self.instance.get_plugins() if self.instance and self.instance.pk else []
settings_holder = self.instance if self.instance and self.instance.pk else self.context['organizer']
allowed_levels = (PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
for plugin in value.get('plugins'):
if plugin not in plugins_available:
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'restricted', False):
if plugin not in settings_holder.settings.allowed_restricted_plugins:
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
level = getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT)
if level not in allowed_levels:
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and plugin not in self.context['organizer'].get_plugins():
if plugin not in current_plugins:
# Technically, this is allowed, but consumers might be confused if the API call doesn't do anything
# so we prevent this change.
raise ValidationError('Plugin should be enabled on organizer level first: \'{name}\'.'.format(name=plugin))
return value

View File

@@ -19,45 +19,16 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import forms
from django.conf import settings
from django.http import QueryDict
from pytz import common_timezones
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):
self.form_field = kwargs.pop('form_field')
super().__init__(*args, **kwargs)
def to_representation(self, value):
return self.form_field.widget.format_value(value)
def to_internal_value(self, data):
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
d = self.form_field.clean(d)
return d
simple_mappings = (
(forms.DateField, serializers.DateField, ()),
(forms.TimeField, serializers.TimeField, ()),
(forms.SplitDateTimeField, serializers.DateTimeField, ()),
(forms.DateTimeField, serializers.DateTimeField, ()),
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
(forms.FloatField, serializers.FloatField, ()),
(forms.IntegerField, serializers.IntegerField, ()),
(forms.EmailField, serializers.EmailField, ()),
(forms.UUIDField, serializers.UUIDField, ()),
(forms.URLField, serializers.URLField, ()),
(forms.BooleanField, serializers.BooleanField, ()),
)
from pretix.base.timeframes import SerializerDateFrameField
class SerializerDescriptionField(serializers.Field):
@@ -81,13 +52,6 @@ class ExporterSerializer(serializers.Serializer):
input_parameters = SerializerDescriptionField(source='_serializer')
class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def to_representation(self, value):
if isinstance(value, int):
return value
return super().to_representation(value)
class JobRunSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
ex = kwargs.pop('exporter')
@@ -102,59 +66,7 @@ class JobRunSerializer(serializers.Serializer):
many=True
)
for k, v in ex.export_form_fields.items():
for m_from, m_to, m_kwargs in simple_mappings:
if isinstance(v, m_from):
self.fields[k] = m_to(
required=v.required,
allow_null=not v.required,
validators=v.validators,
**{kwarg: getattr(v, kwargs, None) for kwarg in m_kwargs}
)
break
if isinstance(v, forms.NullBooleanField):
self.fields[k] = serializers.BooleanField(
required=v.required,
allow_null=True,
validators=v.validators,
)
if isinstance(v, forms.ModelMultipleChoiceField):
self.fields[k] = PrimaryKeyRelatedField(
queryset=v.queryset,
required=v.required,
allow_empty=not v.required,
validators=v.validators,
many=True
)
elif isinstance(v, forms.ModelChoiceField):
self.fields[k] = PrimaryKeyRelatedField(
queryset=v.queryset,
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
elif isinstance(v, forms.MultipleChoiceField):
self.fields[k] = serializers.MultipleChoiceField(
choices=v.choices,
required=v.required,
allow_empty=not v.required,
validators=v.validators,
)
elif isinstance(v, forms.ChoiceField):
self.fields[k] = serializers.ChoiceField(
choices=v.choices,
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
elif isinstance(v, DateFrameField):
self.fields[k] = SerializerDateFrameField(
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
else:
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
self.fields[k] = form_field_to_serializer_field(v)
def to_internal_value(self, data):
if isinstance(data, QueryDict):

View File

@@ -109,3 +109,19 @@ class UploadedFileField(serializers.Field):
return None
request = self.context['request']
return request.build_absolute_uri(url)
class PluginsField(serializers.Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
return sorted([
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
])
def to_internal_value(self, data):
return {
'plugins': data
}

View File

@@ -0,0 +1,115 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import forms
from rest_framework import serializers
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
simple_mappings = (
(forms.DateField, serializers.DateField, ()),
(forms.TimeField, serializers.TimeField, ()),
(forms.SplitDateTimeField, serializers.DateTimeField, ()),
(forms.DateTimeField, serializers.DateTimeField, ()),
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
(forms.FloatField, serializers.FloatField, ()),
(forms.IntegerField, serializers.IntegerField, ()),
(forms.EmailField, serializers.EmailField, ()),
(forms.UUIDField, serializers.UUIDField, ()),
(forms.URLField, serializers.URLField, ()),
(forms.BooleanField, serializers.BooleanField, ()),
)
class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def to_representation(self, value):
if isinstance(value, int):
return value
return super().to_representation(value)
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):
self.form_field = kwargs.pop('form_field')
super().__init__(*args, **kwargs)
def to_representation(self, value):
return self.form_field.widget.format_value(value)
def to_internal_value(self, data):
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
d = self.form_field.clean(d)
return d
def form_field_to_serializer_field(field):
for m_from, m_to, m_kwargs in simple_mappings:
if isinstance(field, m_from):
return m_to(
required=field.required,
allow_null=not field.required,
validators=field.validators,
**{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs}
)
if isinstance(field, forms.NullBooleanField):
return serializers.BooleanField(
required=field.required,
allow_null=True,
validators=field.validators,
)
if isinstance(field, forms.ModelMultipleChoiceField):
return PrimaryKeyRelatedField(
queryset=field.queryset,
required=field.required,
allow_empty=not field.required,
validators=field.validators,
many=True
)
elif isinstance(field, forms.ModelChoiceField):
return PrimaryKeyRelatedField(
queryset=field.queryset,
required=field.required,
allow_null=not field.required,
validators=field.validators,
)
elif isinstance(field, forms.MultipleChoiceField):
return serializers.MultipleChoiceField(
choices=field.choices,
required=field.required,
allow_empty=not field.required,
validators=field.validators,
)
elif isinstance(field, forms.ChoiceField):
return serializers.ChoiceField(
choices=field.choices,
required=field.required,
allow_null=not field.required,
validators=field.validators,
)
elif isinstance(field, DateFrameField):
return SerializerDateFrameField(
required=field.required,
allow_null=not field.required,
validators=field.validators,
)
else:
return FormFieldWrapperField(form_field=field, required=field.required, allow_null=not field.required)

View File

@@ -42,6 +42,7 @@ from rest_framework.reverse import reverse
from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
@@ -49,6 +50,7 @@ from pretix.api.serializers.item import (
from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
@@ -102,6 +104,13 @@ class CountryField(serializers.Field):
return str(src) if src else None
class TransmissionInfoSerializer(serializers.Serializer):
def __init__(self, *args, transmission_type, **kwargs):
super().__init__(*args, **kwargs)
for k, v in transmission_type.invoice_address_form_fields.items():
self.fields[k] = form_field_to_serializer_field(v)
class InvoiceAddressSerializer(I18nAwareModelSerializer):
country = CompatibleCountryField(source='*')
name = serializers.CharField(required=False)
@@ -109,7 +118,8 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceAddress
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference')
'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference', 'transmission_type',
'transmission_info')
read_only_fields = ('last_modified',)
def __init__(self, *args, **kwargs):
@@ -147,6 +157,48 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
if data.get("transmission_type"):
for t in get_transmission_types():
if data.get("transmission_type") == t.identifier:
if not t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The selected transmission type is not available for this country or address type."
})
ts = TransmissionInfoSerializer(transmission_type=t, data=data.get("transmission_info", {}))
try:
ts.is_valid(raise_exception=True)
except ValidationError as e:
raise ValidationError(
{"transmission_info": e.detail}
)
data["transmission_info"] = ts.validated_data
required_fields = t.invoice_address_form_fields_required(data.get("country"), data.get("is_business"))
for r in required_fields:
if r in self.fields:
if not data.get(r):
raise ValidationError(
{r: "This field is required for the selected type of invoice transmission."}
)
else:
if not ts.validated_data.get(r):
raise ValidationError(
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
)
break # do not call else branch of for loop
elif t.exclusive:
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
t.identifier,
)
})
else:
raise ValidationError(
{"transmission_type": "Unknown transmission type."}
)
return data
@@ -1725,12 +1777,13 @@ class InvoiceSerializer(I18nAwareModelSerializer):
model = Invoice
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',
'custom_field', 'date', 'refers', 'locale',
'invoice_to', 'invoice_to_is_business', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street',
'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id',
'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale',
'introductory_text', 'additional_text', 'payment_provider_text', 'payment_provider_stamp',
'footer_text', 'lines', 'foreign_currency_display', 'foreign_currency_rate',
'foreign_currency_rate_date', 'internal_reference')
'foreign_currency_rate_date', 'internal_reference', 'transmission_type', 'transmission_provider',
'transmission_status', 'transmission_date')
class OrderPaymentCreateSerializer(I18nAwareModelSerializer):

View File

@@ -83,6 +83,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
def create(self, validated_data):
ocm = self.context['ocm']
check_quotas = self.context.get('check_quotas', True)
try:
ocm.add_position(
@@ -96,7 +97,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
valid_until=validated_data.get('valid_until'),
)
if self.context.get('commit', True):
ocm.commit()
ocm.commit(check_quotas=check_quotas)
return validated_data['order'].positions.order_by('-positionid').first()
else:
return OrderPosition() # fake to appease DRF
@@ -310,6 +311,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
ocm = self.context['ocm']
check_quotas = self.context.get('check_quotas', True)
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
item = validated_data.get('item', instance.item)
variation = validated_data.get('variation', instance.variation)
@@ -356,7 +358,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
ocm.change_ticket_secret(instance, secret)
if self.context.get('commit', True):
ocm.commit()
ocm.commit(check_quotas=check_quotas)
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))

View File

@@ -24,6 +24,7 @@ from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
@@ -32,6 +33,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.auth.devicesecurity import get_all_security_profiles
from pretix.api.serializers import AsymmetricField
from pretix.api.serializers.fields import PluginsField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
from pretix.api.serializers.settings import SettingsSerializer
@@ -43,6 +45,10 @@ from pretix.base.models import (
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
from pretix.base.services.mail import SendMailException, mail
from pretix.base.settings import validate_organizer_settings
from pretix.helpers.urls import build_absolute_uri as build_global_uri
@@ -53,13 +59,47 @@ logger = logging.getLogger(__name__)
class OrganizerSerializer(I18nAwareModelSerializer):
public_url = serializers.SerializerMethodField('get_organizer_url', read_only=True)
plugins = PluginsField(required=False, source='*')
name = serializers.CharField(read_only=True)
slug = serializers.CharField(read_only=True)
def get_organizer_url(self, organizer):
return build_absolute_uri(organizer, 'presale:organizer.index')
class Meta:
model = Organizer
fields = ('name', 'slug', 'public_url')
fields = ('name', 'slug', 'public_url', 'plugins')
def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module: p for p in get_all_plugins(organizer=self.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
settings_holder = self.instance
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
for plugin in value.get('plugins'):
if plugin not in plugins_available:
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'restricted', False):
if plugin not in settings_holder.settings.allowed_restricted_plugins:
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT) not in allowed_levels:
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
return value
@transaction.atomic
def update(self, instance, validated_data):
plugins = validated_data.pop('plugins', None)
organizer = super().update(instance, validated_data)
# Plugins
if plugins is not None:
organizer.set_active_plugins(plugins)
organizer.save()
return organizer
class SeatingPlanSerializer(I18nAwareModelSerializer):

View File

@@ -70,7 +70,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
class Meta:
model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
fields = ('id', 'created', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat', 'all_addons_included',
'all_bundles_included', 'budget', 'budget_used')

View File

@@ -21,22 +21,22 @@
#
from datetime import timedelta
from django.dispatch import Signal, receiver
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.api.models import ApiCall, WebHookCall
from pretix.base.signals import EventPluginSignal, periodic_task
from pretix.base.signals import EventPluginSignal, GlobalSignal, periodic_task
from pretix.helpers.periodic import minimum_interval
register_webhook_events = Signal()
register_webhook_events = GlobalSignal()
"""
This signal is sent out to get all known webhook events. Receivers should return an
instance of a subclass of ``pretix.api.webhooks.WebhookEvent`` or a list of such
instances.
"""
register_device_security_profile = Signal()
register_device_security_profile = GlobalSignal()
"""
This signal is sent out to get all known device security_profiles. Receivers should
return an instance of a subclass of ``pretix.api.auth.devicesecurity.BaseSecurityProfile``

View File

@@ -132,6 +132,8 @@ urlpatterns = [
name="checkinrpc.redeem"),
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/search/$', checkin.CheckinRPCSearchView.as_view(),
name="checkinrpc.search"),
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/annul/$', checkin.CheckinRPCAnnulView.as_view(),
name="checkinrpc.annul"),
re_path(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(),
name="organizer.settings"),
re_path(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)),

View File

@@ -20,12 +20,13 @@
# <https://www.gnu.org/licenses/>.
#
import operator
from datetime import timedelta
from functools import reduce
import django_filters
from django.conf import settings
from django.core.exceptions import ValidationError as BaseValidationError
from django.db import transaction
from django.db import connection, transaction
from django.db.models import (
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
prefetch_related_objects,
@@ -39,17 +40,19 @@ from django.utils.translation import gettext
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from packaging.version import parse
from rest_framework import views, viewsets
from rest_framework import status, views, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError,
)
from rest_framework.fields import DateTimeField
from rest_framework.generics import ListAPIView
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from pretix.api.serializers.checkin import (
CheckinListSerializer, CheckinRPCRedeemInputSerializer,
MiniCheckinListSerializer,
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
)
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import (
@@ -66,6 +69,8 @@ from pretix.base.models.orders import PrintLog
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
)
from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF
with scopes_disabled():
class CheckinListFilter(FilterSet):
@@ -813,7 +818,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['expand'] = self.request.query_params.getlist('expand')
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
return ctx
def get_filterset_kwargs(self):
@@ -832,9 +837,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self, ignore_status=False, ignore_products=False):
qs = _checkin_list_position_queryset(
[self.checkinlist],
ignore_status=self.request.query_params.get('ignore_status', 'false') == 'true' or ignore_status,
ignore_status=self.request.query_params.get('ignore_status', 'false').lower() == 'true' or ignore_status,
ignore_products=ignore_products,
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
expand=self.request.query_params.getlist('expand'),
)
@@ -876,7 +881,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
user=self.request.user,
auth=self.request.auth,
expand=self.request.query_params.getlist('expand'),
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
questions_supported=self.request.data.get('questions_supported', True),
canceled_supported=self.request.data.get('canceled_supported', False),
request=self.request, # this is not clean, but we need it in the serializers for URL generation
@@ -911,7 +916,7 @@ class CheckinRPCRedeemView(views.APIView):
user=self.request.user,
auth=self.request.auth,
expand=self.request.query_params.getlist('expand'),
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
questions_supported=s.validated_data['questions_supported'],
use_order_locale=s.validated_data['use_order_locale'],
canceled_supported=True,
@@ -989,9 +994,9 @@ class CheckinRPCSearchView(ListAPIView):
def get_queryset(self, ignore_status=False, ignore_products=False):
qs = _checkin_list_position_queryset(
self.lists,
ignore_status=self.request.query_params.get('ignore_status', 'false') == 'true' or ignore_status,
ignore_status=self.request.query_params.get('ignore_status', 'false').lower() == 'true' or ignore_status,
ignore_products=ignore_products,
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true',
expand=self.request.query_params.getlist('expand'),
)
@@ -999,3 +1004,79 @@ class CheckinRPCSearchView(ListAPIView):
qs = qs.none()
return qs
class CheckinRPCAnnulView(views.APIView):
def post(self, request, *args, **kwargs):
if isinstance(self.request.auth, (TeamAPIToken, Device)):
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
elif self.request.user.is_authenticated:
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
organizer=self.request.organizer
)
else:
raise ValueError("unknown authentication method")
s = CheckinRPCAnnulInputSerializer(data=request.data, context={'events': events})
s.is_valid(raise_exception=True)
with transaction.atomic():
try:
qs = Checkin.all.all()
if isinstance(request.auth, Device):
qs = qs.filter(device=request.auth)
ci = qs.select_for_update(
of=OF_SELF,
).select_related("position", "position__order", "position__order__event").get(
list__in=s.validated_data['lists'],
nonce=s.validated_data['nonce'],
)
if connection.features.has_select_for_update_of and ci.position_id:
# Lock position as well, can't do it with of= above because relation is nullable
OrderPosition.objects.select_for_update(of=OF_SELF).get(pk=ci.position_id)
if not ci.successful or not ci.position:
raise ValidationError("Cannot annul an unsuccessful checkin")
except Checkin.DoesNotExist:
raise NotFound("No check-in found based on nonce")
except Checkin.MultipleObjectsReturned:
raise ValidationError("Multiple check-ins found based on nonce")
annulment_time = s.validated_data.get("datetime") or now()
if annulment_time - ci.datetime > timedelta(minutes=15):
# Compare to sent datetime, which makes this cheatable, but allows offline annulment of checkins
ci.position.order.log_action('pretix.event.checkin.annulment.ignored', data={
'checkin': ci.pk,
'position': ci.position.id,
'positionid': ci.position.positionid,
'datetime': annulment_time,
'error_explanation': s.validated_data.get("error_explanation"),
'type': ci.type,
'list': ci.list_id,
}, user=request.user, auth=request.auth)
return Response({
"non_field_errors": ["Annulment is not allowed more than 15 minutes after check-in"]
}, status=status.HTTP_400_BAD_REQUEST)
if ci.device and ci.device != request.auth:
return Response({
"non_field_errors": ["Annulment is only allowed from the same device"]
}, status=status.HTTP_400_BAD_REQUEST)
ci.successful = False
ci.error_reason = Checkin.REASON_ANNULLED
ci.error_explanation = s.validated_data.get("error_explanation")
ci.save(update_fields=["successful", "error_reason", "error_explanation"])
ci.position.order.log_action('pretix.event.checkin.annulled', data={
'checkin': ci.pk,
'position': ci.position.id,
'positionid': ci.position.positionid,
'datetime': annulment_time,
'error_explanation': s.validated_data.get("error_explanation"),
'type': ci.type,
'list': ci.list_id,
}, user=request.user, auth=request.auth)
checkin_annulled.send(ci.position.order.event, checkin=ci)
return Response({"status": "ok"}, status=status.HTTP_200_OK)

View File

@@ -88,7 +88,7 @@ from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice,
regenerate_invoice, transmit_invoice,
)
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import (
@@ -228,7 +228,7 @@ class OrderViewSetMixin:
def get_queryset(self):
qs = self.get_base_queryset()
if 'fees' not in self.request.GET.getlist('exclude'):
if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
if self.request.query_params.get('include_canceled_fees', 'false').lower() == 'true':
fqs = OrderFee.all
else:
fqs = OrderFee.objects
@@ -246,11 +246,11 @@ class OrderViewSetMixin:
return qs
def _positions_prefetch(self, request):
if request.query_params.get('include_canceled_positions', 'false') == 'true':
if request.query_params.get('include_canceled_positions', 'false').lower() == 'true':
opq = OrderPosition.all
else:
opq = OrderPosition.objects
if request.query_params.get('pdf_data', 'false') == 'true' and getattr(request, 'event', None):
if request.query_params.get('pdf_data', 'false').lower() == 'true' and getattr(request, 'event', None):
prefetch_related_objects([request.organizer], 'meta_properties')
prefetch_related_objects(
[request.event],
@@ -344,7 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
return ctx
def get_base_queryset(self):
@@ -943,6 +943,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
@action(detail=True, methods=['POST'])
def change(self, request, **kwargs):
order = self.get_object()
check_quotas = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
serializer = OrderChangeOperationSerializer(
context={'order': order, **self.get_serializer_context()},
@@ -1008,7 +1009,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
ocm.recalculate_taxes(keep='gross')
ocm.commit()
ocm.commit(check_quotas=check_quotas)
except OrderError as e:
raise ValidationError(str(e))
@@ -1086,17 +1087,18 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
return ctx
def get_queryset(self):
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
if self.request.query_params.get('include_canceled_positions', 'false').lower() == 'true':
qs = OrderPosition.all
else:
qs = OrderPosition.objects
qs = qs.filter(order__event=self.request.event)
if self.request.query_params.get('pdf_data', 'false') == 'true':
if self.request.query_params.get('pdf_data', 'false').lower() == 'true':
prefetch_related_objects([self.request.organizer], 'meta_properties')
prefetch_related_objects(
[self.request.event],
@@ -1889,6 +1891,12 @@ class RetryException(APIException):
default_code = 'retry_later'
class CurrentlyInflightException(APIException):
status_code = 409
default_detail = 'The requested action is already in progress.'
default_code = 'currently_inflight'
class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = InvoiceSerializer
queryset = Invoice.objects.none()
@@ -1937,13 +1945,52 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
@action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs):
invoice = self.get_object()
if invoice.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
if invoice.transmission_status != Invoice.TRANSMISSION_STATUS_PENDING:
raise PermissionDenied('The invoice is not in pending state.')
transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, False))
return Response(status=204)
@action(detail=True, methods=['POST'])
def retransmit(self, request, **kwargs):
invoice = self.get_object()
if invoice.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
with transaction.atomic(durable=True):
invoice = Invoice.objects.select_for_update(of=OF_SELF).get(pk=invoice.pk)
if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT:
raise CurrentlyInflightException()
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
'pretix.event.order.invoice.retransmitted',
user=self.request.user,
auth=self.request.auth,
data={
'invoice': invoice.pk,
'full_invoice_no': invoice.full_invoice_no,
}
)
transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, True))
return Response(status=204)
@action(detail=True, methods=['POST'])
def regenerate(self, request, **kwargs):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
if not inv.event.settings.invoice_regenerate_allowed:
raise PermissionDenied('Invoices may not be changed after they are created.')
if not inv.regenerate_allowed:
raise PermissionDenied('Invoice may not be regenerated.')
elif inv.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
elif inv.sent_to_organizer:

View File

@@ -19,7 +19,9 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import operator
from decimal import Decimal
from functools import reduce
import django_filters
from django.contrib.auth.hashers import make_password
@@ -48,15 +50,18 @@ from pretix.api.serializers.organizer import (
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
)
from pretix.base.models import (
Customer, Device, GiftCard, GiftCardTransaction, Membership,
MembershipType, Organizer, SalesChannel, SeatingPlan, Team, TeamAPIToken,
TeamInvite, User,
Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry,
Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team,
TeamAPIToken, TeamInvite, User,
)
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
)
from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrganizerSerializer
queryset = Organizer.objects.none()
lookup_field = 'slug'
@@ -65,6 +70,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
filter_backends = (TotalOrderingFilter,)
ordering = ('slug',)
ordering_fields = ('name', 'slug')
write_permission = "can_change_organizer_settings"
def get_queryset(self):
if self.request.user.is_authenticated:
@@ -83,6 +89,67 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
@transaction.atomic()
def perform_update(self, serializer):
from pretix.base.plugins import get_all_plugins
original_data = self.get_serializer(instance=serializer.instance).data
current_plugins_value = serializer.instance.get_plugins()
updated_plugins_value = serializer.validated_data.get('plugins', None)
super().perform_update(serializer)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
if updated_plugins_value is not None and set(updated_plugins_value) != set(current_plugins_value):
enabled = {m: 'enabled' for m in updated_plugins_value if m not in current_plugins_value}
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled)
plugins_available = {
p.module: p
for p in get_all_plugins(organizer=serializer.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
qs = []
for module in disabled:
pluginmeta = plugins_available[module]
level = getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
qs.append(Q(plugins__regex='(^|,)' + module + '(,|$)'))
if qs:
events_to_disable = set(self.request.organizer.events.filter(
reduce(operator.or_, qs)
).values_list("pk", flat=True))
logentries_to_save = []
events_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_disable):
for module in disabled:
if module in e.get_plugins():
logentries_to_save.append(
e.log_action('pretix.event.plugins.disabled', user=self.request.user, auth=self.request.auth,
data={'plugin': module}, save=False)
)
e.disable_plugin(module)
events_to_save.append(e)
Event.objects.bulk_update(events_to_save, fields=["plugins"])
LogEntry.objects.bulk_create(logentries_to_save)
for module, operation in changed.items():
serializer.instance.log_action(
'pretix.organizer.plugins.' + operation,
user=self.request.user,
auth=self.request.auth,
data={'plugin': module}
)
class SeatingPlanViewSet(viewsets.ModelViewSet):
serializer_class = SeatingPlanSerializer

View File

@@ -78,6 +78,13 @@ class WebhookEvent:
"""
raise NotImplementedError() # NOQA
@property
def help_text(self) -> str:
"""
A human-readable description
"""
return ""
def get_all_webhook_events():
global _ALL_EVENTS
@@ -97,9 +104,10 @@ def get_all_webhook_events():
class ParametrizedWebhookEvent(WebhookEvent):
def __init__(self, action_type, verbose_name):
def __init__(self, action_type, verbose_name, help_text=""):
self._action_type = action_type
self._verbose_name = verbose_name
self._help_text = help_text
super().__init__()
@property
@@ -110,6 +118,10 @@ class ParametrizedWebhookEvent(WebhookEvent):
def verbose_name(self):
return self._verbose_name
@property
def help_text(self):
return self._help_text
class ParametrizedOrderWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
@@ -161,6 +173,19 @@ class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent):
}
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
# do not use content_object, this is also called in deletion
return {
'notification_id': logentry.pk,
'organizer': logentry.event.organizer.slug,
'event': logentry.event.slug,
'voucher': logentry.object_id,
'action': logentry.action_type,
}
class ParametrizedSubEventWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
@@ -346,8 +371,9 @@ def register_default_webhook_events(sender, **kwargs):
),
ParametrizedItemWebhookEvent(
'pretix.event.item.*',
_('Product changed (including product added or deleted and including changes to nested objects like '
'variations or bundles)'),
_('Product changed'),
_('This includes product added or deleted and changes to nested objects like '
'variations or bundles.'),
),
ParametrizedEventWebhookEvent(
'pretix.event.live.activated',
@@ -381,6 +407,19 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.orders.waitinglist.voucher_assigned',
_('Waiting list entry received voucher'),
),
ParametrizedVoucherWebhookEvent(
'pretix.voucher.added',
_('Voucher added'),
),
ParametrizedVoucherWebhookEvent(
'pretix.voucher.changed',
_('Voucher changed'),
_('Only includes explicit changes to the voucher, not e.g. an increase of the number of redemptions.')
),
ParametrizedVoucherWebhookEvent(
'pretix.voucher.deleted',
_('Voucher deleted'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.created',
_('Customer account created'),

View File

@@ -43,10 +43,10 @@ class PretixBaseConfig(AppConfig):
from . import exporter # NOQA
from . import payment # NOQA
from . import exporters # NOQA
from . import invoice # NOQA
from .invoicing import pdf, transmission, email, peppol, national # NOQA
from . import notifications # NOQA
from . import email # NOQA
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .services import auth, checkin, currencies, datasync, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .models import _transactions # NOQA
from django.conf import settings

View File

@@ -199,6 +199,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
params['client_id'] = provider.configuration['client_id']
params['client_secret'] = provider.configuration['client_secret']
resp = None
try:
resp = requests.post(
endpoint,
@@ -214,7 +215,10 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
resp.raise_for_status()
data = resp.json()
except RequestException:
logger.exception('Could not retrieve authorization token')
if resp:
logger.exception(f'Could not retrieve authorization token. Response: {resp.text}')
else:
logger.exception('Could not retrieve authorization token')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not reach login provider',
@@ -222,6 +226,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
)
if 'access_token' not in data:
logger.error(f'Could not find access token. Response: {data}')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='access token missing',
@@ -229,6 +234,7 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
)
endpoint = provider.configuration['provider_config']['userinfo_endpoint']
resp = None
try:
# https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
resp = requests.get(
@@ -240,7 +246,10 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
resp.raise_for_status()
userinfo = resp.json()
except RequestException:
logger.exception('Could not retrieve user info')
if resp:
logger.exception(f'Could not retrieve user info. Response: {resp.text}')
else:
logger.exception('Could not retrieve user info')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not fetch user info',

View File

@@ -0,0 +1,21 @@
#
# 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/>.
#

View File

@@ -0,0 +1,436 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
import logging
from collections import namedtuple
from datetime import timedelta
from functools import cached_property
from typing import List, Optional, Protocol
import sentry_sdk
from django.db import DatabaseError, transaction
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from pretix.base.datasync.sourcefields import (
EVENT, EVENT_OR_SUBEVENT, ORDER, ORDER_POSITION, get_data_fields,
)
from pretix.base.i18n import language
from pretix.base.logentrytype_registry import make_link
from pretix.base.models.datasync import OrderSyncQueue, OrderSyncResult
from pretix.base.signals import PluginAwareRegistry
from pretix.helpers import OF_SELF
logger = logging.getLogger(__name__)
datasync_providers = PluginAwareRegistry({"identifier": lambda o: o.identifier})
class BaseSyncError(Exception):
def __init__(self, messages, full_message=None):
self.messages = messages
self.full_message = full_message
class UnrecoverableSyncError(BaseSyncError):
"""
A SyncProvider encountered a permanent problem, where a retry will not be successful.
"""
failure_mode = "permanent"
class SyncConfigError(UnrecoverableSyncError):
"""
A SyncProvider is misconfigured in a way where a retry without configuration change will
not be successful.
"""
failure_mode = "config"
class RecoverableSyncError(BaseSyncError):
"""
A SyncProvider has encountered a temporary problem, and the sync should be retried
at a later time.
"""
pass
class ObjectMapping(Protocol):
id: int
pretix_model: str
external_object_type: str
pretix_id_field: str
external_id_field: str
property_mappings: str
StaticMapping = namedtuple('StaticMapping', ('id', 'pretix_model', 'external_object_type', 'pretix_id_field', 'external_id_field', 'property_mappings'))
class OutboundSyncProvider:
max_attempts = 5
def __init__(self, event):
self.event = event
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
@classmethod
@property
def display_name(cls):
return str(cls.identifier)
@classmethod
def enqueue_order(cls, order, triggered_by, not_before=None):
"""
Adds an order to the sync queue. May only be called on derived classes which define an ``identifier`` attribute.
Should be called in the appropriate signal receivers, e.g.::
@receiver(order_placed, dispatch_uid="mysync_order_placed")
def on_order_placed(sender, order, **kwargs):
MySyncProvider.enqueue_order(order, "order_placed")
:param order: the Order that should be synced
:param triggered_by: the reason why the order should be synced, e.g. name of the signal
(currently only used internally for logging)
"""
if not hasattr(cls, 'identifier'):
raise TypeError('Call this method on a derived class that defines an "identifier" attribute.')
OrderSyncQueue.objects.update_or_create(
order=order,
sync_provider=cls.identifier,
in_flight=False,
defaults={
"event": order.event,
"triggered_by": triggered_by,
"not_before": not_before or now(),
"need_manual_retry": None,
},
)
@classmethod
def get_external_link_info(cls, event, external_link_href, external_link_display_name):
return {
"href": external_link_href,
"val": external_link_display_name,
}
@classmethod
def get_external_link_html(cls, event, external_link_href, external_link_display_name):
info = cls.get_external_link_info(event, external_link_href, external_link_display_name)
return make_link(info, '{val}')
def next_retry_date(self, sq):
"""
Optionally override to configure a different retry backoff behavior
"""
return now() + timedelta(hours=1)
def should_sync_order(self, order):
"""
Optionally override this method to exclude certain orders from sync by returning ``False``
"""
return True
@property
def mappings(self):
"""
Implementations must override this property to provide the data mappings as a list of objects.
They can return instances of the ``StaticMapping`` `namedtuple` defined above, or create their own
class (e.g. a Django model).
:return: The returned objects must have at least the following properties:
- `id`: Unique identifier for this mapping. If the mappings are Django models, the database primary key
should be used. This may be referenced in other mappings, to establish relations between objects.
- `pretix_model`: Which pretix model to use as data source in this mapping. Possible values are
the keys of ``sourcefields.AVAILABLE_MODELS``
- `external_object_type`: Destination object type in the target system. opaque string of maximum 128 characters.
- `pretix_id_field`: Which pretix data field should be used to identify the mapped object. Any ``DataFieldInfo.key``
returned by ``sourcefields.get_data_fields()`` for the combination of ``Event`` and ``pretix_model``.
- `external_id_field`: Destination identifier field in the target system.
- `property_mappings`: Mapping configuration as generated by ``PropertyMappingFormSet.to_property_mappings_list()``.
"""
raise NotImplementedError
def sync_queued_orders(self, queued_orders):
"""
This method should catch all Exceptions and handle them appropriately. It should never throw
an Exception, as that may block the entire queue.
"""
for queue_item in queued_orders:
with transaction.atomic():
try:
sq = (
OrderSyncQueue.objects
.select_for_update(of=OF_SELF, nowait=True)
.select_related("order")
.get(pk=queue_item.pk)
)
if sq.in_flight:
continue
sq.in_flight = True
sq.in_flight_since = now()
sq.save()
except DatabaseError:
# Either select_for_update failed to lock the row, or we couldn't set in_flight
# as this order is already in flight (UNIQUE violation). In either case, we ignore
# this order for now.
continue
try:
mapped_objects = self.sync_order(sq.order)
if not all(all(not res or res.sync_info.get("action", "") == "nothing_to_do" for res in res_list) for res_list in mapped_objects.values()):
sq.order.log_action("pretix.event.order.data_sync.success", {
"provider": self.identifier,
"objects": {
mapping_id: [osr and osr.to_result_dict() for osr in results]
for mapping_id, results in mapped_objects.items()
},
})
sq.delete()
except UnrecoverableSyncError as e:
sq.set_sync_error(e.failure_mode, e.messages, e.full_message)
except RecoverableSyncError as e:
sq.failed_attempts += 1
sq.not_before = self.next_retry_date(sq)
# model changes saved by set_sync_error / clear_in_flight calls below
if sq.failed_attempts >= self.max_attempts:
logger.exception('Failed to sync order (max attempts exceeded)')
sentry_sdk.capture_exception(e)
sq.set_sync_error("exceeded", e.messages, e.full_message)
else:
logger.info(
f"Could not sync order {sq.order.code} to {type(self).__name__} "
f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})",
exc_info=True,
)
sq.clear_in_flight()
except Exception as e:
logger.exception('Failed to sync order (unhandled exception)')
sentry_sdk.capture_exception(e)
sq.set_sync_error("internal", [], str(e))
@cached_property
def data_fields(self):
return {
f.key: f
for f in get_data_fields(self.event)
}
def get_field_value(self, inputs, mapping_entry):
key = mapping_entry["pretix_field"]
try:
field = self.data_fields[key]
except KeyError:
with language(self.event.settings.locale):
raise SyncConfigError([_(
'Field "{field_name}" is not valid for {available_inputs}. Please check your {provider_name} settings.'
).format(key=key, available_inputs="/".join(inputs.keys()), provider_name=self.display_name)])
input = inputs[field.required_input]
val = field.getter(input)
if isinstance(val, list):
if field.enum_opts and mapping_entry.get("value_map"):
map = json.loads(mapping_entry["value_map"])
try:
val = [map[el] for el in val]
except KeyError:
with language(self.event.settings.locale):
raise SyncConfigError([_(
'Please update value mapping for field "{field_name}" - option "{val}" not assigned'
).format(field_name=key, val=val)])
val = ",".join(val)
return val
def get_properties(self, inputs: dict, property_mappings: List[dict]):
return [
(m["external_field"], self.get_field_value(inputs, m), m["overwrite"])
for m in property_mappings
]
def sync_object_with_properties(
self,
external_id_field: str,
id_value,
properties: list,
inputs: dict,
mapping: ObjectMapping,
mapped_objects: dict,
**kwargs,
) -> Optional[dict]:
"""
This method is called for each object that needs to be created/updated in the external system -- which these are is
determined by the implementation of the `mapping` property.
:param external_id_field: Identifier field in the external system as provided in ``mapping.external_identifier``
:param id_value: Identifier contents as retrieved from the property specified by ``mapping.pretix_identifier`` of the model
specified by ``mapping.pretix_model``
:param properties: All properties defined in ``mapping.property_mappings``, as list of three-tuples
``(external_field, value, overwrite)``
:param inputs: All pretix model instances from which data can be retrieved for this mapping.
Dictionary mapping from sourcefields.ORDER_POSITION, .ORDER, .EVENT, .EVENT_OR_SUBEVENT to the
relevant Django model.
Most providers don't need to use this parameter directly, as `properties` and `id_value`
already contain the values as evaluated from the available inputs.
:param mapping: The mapping object as returned by ``self.mappings``
:param mapped_objects: Information about objects that were synced in the same sync run, by mapping definitions
*before* the current one in order of ``self.mappings``.
Type is a dictionary ``{mapping.id: [list of OrderSyncResult objects]}``
Useful to create associations between objects in the target system.
Example code to create return value::
return {
# optional:
"action": "nothing_to_do", # to inform that no action was taken, because the data was already up-to-date.
# other values for action (e.g. create, update) currently have no special
# meaning, but are visible for debugging purposes to admins.
# optional:
"external_link_href": "https://external-system.example.com/backend/link/to/contact/123/",
"external_link_display_name": "Contact #123 - Jane Doe",
"...optionally further values you need in mapped_objects for association": 123456789,
}
The return value needs to be a JSON serializable dict, or None.
Return None only in case you decide this object should not be synced at all in this mapping. Do not return None in
case the object is already up-to-date in the target system (return "action": "nothing_to_do" instead).
This method needs to be idempotent, i.e. calling it multiple times with the same input values should create
only a single object in the target system.
Subsequent calls with the same mapping and id_value should update the existing object, instead of creating a new one.
In a SQL database, you might use an `INSERT OR UPDATE` or `UPSERT` statement; many REST APIs provide an equivalent API call.
"""
raise NotImplementedError()
def sync_object(
self,
inputs: dict,
mapping,
mapped_objects: dict,
):
logger.debug("Syncing object %r, %r, %r", inputs, mapping, mapped_objects)
properties = self.get_properties(inputs, mapping.property_mappings)
logger.debug("Properties: %r", properties)
id_value = self.get_field_value(inputs, {"pretix_field": mapping.pretix_id_field})
if not id_value:
return None
info = self.sync_object_with_properties(
external_id_field=mapping.external_id_field,
id_value=id_value,
properties=properties,
inputs=inputs,
mapping=mapping,
mapped_objects=mapped_objects,
)
if not info:
return None
external_link_href = info.pop('external_link_href', None)
external_link_display_name = info.pop('external_link_display_name', None)
obj, created = OrderSyncResult.objects.update_or_create(
order=inputs.get(ORDER), order_position=inputs.get(ORDER_POSITION), sync_provider=self.identifier,
mapping_id=mapping.id,
defaults=dict(
external_object_type=mapping.external_object_type,
external_id_field=mapping.external_id_field,
id_value=id_value,
external_link_href=external_link_href,
external_link_display_name=external_link_display_name,
sync_info=info,
transmitted=now(),
)
)
return obj
def sync_order(self, order):
if not self.should_sync_order(order):
logger.debug("Skipping order %r", order)
return
logger.debug("Syncing order %r", order)
positions = list(
order.all_positions
.prefetch_related("answers", "answers__question")
.select_related(
"voucher",
)
)
order_inputs = {ORDER: order, EVENT: self.event}
mapped_objects = {}
for mapping in self.mappings:
if mapping.pretix_model == 'Order':
mapped_objects[mapping.id] = [
self.sync_object(order_inputs, mapping, mapped_objects)
]
elif mapping.pretix_model == 'OrderPosition':
mapped_objects[mapping.id] = [
self.sync_object({
**order_inputs, EVENT_OR_SUBEVENT: op.subevent or self.event, ORDER_POSITION: op
}, mapping, mapped_objects)
for op in positions
]
else:
raise SyncConfigError("Invalid pretix model '{}'".format(mapping.pretix_model))
self.finalize_sync_order(order)
return mapped_objects
def filter_mapped_objects(self, mapped_objects, inputs):
"""
For order positions, only
"""
if ORDER_POSITION in inputs:
return {
mapping_id: [
osr for osr in results
if osr and (osr.order_position_id is None or osr.order_position_id == inputs[ORDER_POSITION].id)
]
for mapping_id, results in mapped_objects.items()
}
else:
return mapped_objects
def finalize_sync_order(self, order):
"""
Called after ``sync_object`` has been called successfully for all objects of a specific order. Can
be used for saving bulk information per order.
"""
pass
def close(self):
"""
Called after all orders of an event have been synced. Can be used for clean-up tasks (e.g. closing
a session).
"""
pass

View File

@@ -0,0 +1,659 @@
#
# 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 collections import namedtuple
from functools import partial
from django.db.models import Max, Q
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import Checkin, InvoiceAddress, Order, Question
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.multidomain.urlreverse import build_absolute_uri
def get_answer(op, question_identifier=None):
a = None
if op.addon_to:
if "answers" in getattr(op.addon_to, "_prefetched_objects_cache", {}):
try:
a = [
a
for a in op.addon_to.answers.all()
if a.question.identifier == question_identifier
][0]
except IndexError:
pass
else:
a = op.addon_to.answers.filter(
question__identifier=question_identifier
).first()
if "answers" in getattr(op, "_prefetched_objects_cache", {}):
try:
a = [
a
for a in op.answers.all()
if a.question.identifier == question_identifier
][0]
except IndexError:
pass
else:
a = op.answers.filter(question__identifier=question_identifier).first()
if not a:
return ""
else:
if a.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return [str(o.identifier) for o in a.options.all()]
if a.question.type == Question.TYPE_BOOLEAN:
return a.answer == "True"
return a.answer
def get_payment_date(order):
if order.status == Order.STATUS_PENDING:
return None
return isoformat_or_none(order.payments.aggregate(m=Max("payment_date"))["m"])
def isoformat_or_none(dt):
return dt and dt.isoformat()
def first_checkin_on_list(list_pk, position):
checkin = position.checkins.filter(
list__pk=list_pk, type=Checkin.TYPE_ENTRY
).first()
if checkin:
return isoformat_or_none(checkin.datetime)
def split_name_on_last_space(name, part):
name_parts = name.rsplit(" ", 1)
return name_parts[part] if len(name_parts) > part else ""
def normalize_email(email):
if email:
local, host = email.split("@")
host = host.encode("idna").decode()
return f"{local}@{host}"
else:
return None
def get_email_domain(email):
if email:
local, host = email.split("@")
return host
else:
return None
ORDER_POSITION = 'position'
ORDER = 'order'
EVENT = 'event'
EVENT_OR_SUBEVENT = 'event_or_subevent'
AVAILABLE_MODELS = {
'OrderPosition': (ORDER_POSITION, ORDER, EVENT_OR_SUBEVENT, EVENT),
'Order': (ORDER, EVENT),
}
DataFieldCategory = namedtuple(
'DataFieldCategory',
field_names=('sort_index', 'label',),
)
CAT_ORDER_POSITION = DataFieldCategory(10, _('Order position details'))
CAT_ATTENDEE = DataFieldCategory(11, _('Attendee details'))
CAT_QUESTIONS = DataFieldCategory(12, _('Questions'))
CAT_PRODUCT = DataFieldCategory(20, _('Product details'))
CAT_ORDER = DataFieldCategory(21, _('Order details'))
CAT_INVOICE_ADDRESS = DataFieldCategory(22, _('Invoice address'))
CAT_EVENT = DataFieldCategory(30, _('Event information'))
CAT_EVENT_OR_SUBEVENT = DataFieldCategory(31, pgettext_lazy('subevent', 'Event or date information'))
DataFieldInfo = namedtuple(
'DataFieldInfo',
field_names=('required_input', 'category', 'key', 'label', 'type', 'enum_opts', 'getter', 'deprecated'),
defaults=[False]
)
def get_invoice_address_or_empty(order):
try:
return order.invoice_address
except InvoiceAddress.DoesNotExist:
return InvoiceAddress()
def get_data_fields(event, for_model=None):
"""
Returns tuple of (required_input, key, label, type, enum_opts, getter)
Type is one of the Question types as defined in Question.TYPE_CHOICES.
The data type of the return value of `getter` depends on `type`:
- TYPE_CHOICE_MULTIPLE: list of strings
- TYPE_CHOICE: list, containing zero or one strings
- TYPE_BOOLEAN: boolean
- all other (including TYPE_NUMBER): string
"""
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
name_headers = []
if name_scheme and len(name_scheme["fields"]) > 1:
for k, label, w in name_scheme["fields"]:
name_headers.append(label)
src_fields = (
[
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_name",
_("Attendee name"),
Question.TYPE_STRING,
None,
lambda position: position.attendee_name
or (position.addon_to.attendee_name if position.addon_to else None),
),
]
+ [
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_name_" + k,
_("Attendee") + ": " + label,
Question.TYPE_STRING,
None,
partial(
lambda k, position: (
position.attendee_name_parts
or (position.addon_to.attendee_name_parts if position.addon_to else {})
or {}
).get(k, ""),
k,
),
deprecated=len(name_scheme["fields"]) == 1,
)
for k, label, w in name_scheme["fields"]
]
+ [
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_email",
_("Attendee email"),
Question.TYPE_STRING,
None,
lambda position: normalize_email(
position.attendee_email
or (position.addon_to.attendee_email if position.addon_to else None)
),
),
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_or_order_email",
_("Attendee or order email"),
Question.TYPE_STRING,
None,
lambda position: normalize_email(
position.attendee_email
or (position.addon_to.attendee_email if position.addon_to else None)
or position.order.email
),
),
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_company",
_("Attendee company"),
Question.TYPE_STRING,
None,
lambda position: position.company or (position.addon_to.company if position.addon_to else None),
),
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_street",
_("Attendee address street"),
Question.TYPE_STRING,
None,
lambda position: position.street or (position.addon_to.street if position.addon_to else None),
),
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_zipcode",
_("Attendee address ZIP code"),
Question.TYPE_STRING,
None,
lambda position: position.zipcode or (position.addon_to.zipcode if position.addon_to else None),
),
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_city",
_("Attendee address city"),
Question.TYPE_STRING,
None,
lambda position: position.city or (position.addon_to.city if position.addon_to else None),
),
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_country",
_("Attendee address country"),
Question.TYPE_COUNTRYCODE,
None,
lambda position: str(
position.country or (position.addon_to.attendee_name if position.addon_to else "")
),
),
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_company",
_("Invoice address company"),
Question.TYPE_STRING,
None,
lambda order: get_invoice_address_or_empty(order).company,
),
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_name",
_("Invoice address name"),
Question.TYPE_STRING,
None,
lambda order: get_invoice_address_or_empty(order).name,
),
]
+ [
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_name_" + k,
_("Invoice address") + ": " + label,
Question.TYPE_STRING,
None,
partial(
lambda k, order: (get_invoice_address_or_empty(order).name_parts or {}).get(
k, ""
),
k,
),
deprecated=len(name_scheme["fields"]) == 1,
)
for k, label, w in name_scheme["fields"]
]
+ [
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_street",
_("Invoice address street"),
Question.TYPE_STRING,
None,
lambda order: get_invoice_address_or_empty(order).street,
),
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_zipcode",
_("Invoice address ZIP code"),
Question.TYPE_STRING,
None,
lambda order: get_invoice_address_or_empty(order).zipcode,
),
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_city",
_("Invoice address city"),
Question.TYPE_STRING,
None,
lambda order: get_invoice_address_or_empty(order).city,
),
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_country",
_("Invoice address country"),
Question.TYPE_COUNTRYCODE,
None,
lambda order: str(get_invoice_address_or_empty(order).country),
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"email",
_("Order email"),
Question.TYPE_STRING,
None,
lambda order: normalize_email(order.email),
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"email_domain",
_("Order email domain"),
Question.TYPE_STRING,
None,
lambda order: get_email_domain(normalize_email(order.email)),
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"order_code",
_("Order code"),
Question.TYPE_STRING,
None,
lambda order: order.code,
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"event_order_code",
_("Event and order code"),
Question.TYPE_STRING,
None,
lambda order: order.full_code,
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"order_total",
_("Order total"),
Question.TYPE_NUMBER,
None,
lambda order: str(order.total),
),
DataFieldInfo(
ORDER_POSITION,
CAT_PRODUCT,
"product",
_("Product and variation name"),
Question.TYPE_STRING,
None,
lambda position: str(
str(position.item.internal_name or position.item.name)
+ ((" " + str(position.variation.value)) if position.variation else "")
),
),
DataFieldInfo(
ORDER_POSITION,
CAT_PRODUCT,
"product_id",
_("Product ID"),
Question.TYPE_NUMBER,
None,
lambda position: str(position.item.pk),
),
DataFieldInfo(
ORDER_POSITION,
CAT_PRODUCT,
"product_is_admission",
_("Product is admission product"),
Question.TYPE_BOOLEAN,
None,
lambda position: bool(position.item.admission),
),
DataFieldInfo(
EVENT,
CAT_EVENT,
"event_slug",
_("Event short form"),
Question.TYPE_STRING,
None,
lambda event: str(event.slug),
),
DataFieldInfo(
EVENT,
CAT_EVENT,
"event_name",
_("Event name"),
Question.TYPE_STRING,
None,
lambda event: str(event.name),
),
DataFieldInfo(
EVENT_OR_SUBEVENT,
CAT_EVENT_OR_SUBEVENT,
"event_date_from",
_("Event start date"),
Question.TYPE_DATETIME,
None,
lambda event_or_subevent: isoformat_or_none(event_or_subevent.date_from),
),
DataFieldInfo(
EVENT_OR_SUBEVENT,
CAT_EVENT_OR_SUBEVENT,
"event_date_to",
_("Event end date"),
Question.TYPE_DATETIME,
None,
lambda event_or_subevent: isoformat_or_none(event_or_subevent.date_to),
),
DataFieldInfo(
ORDER_POSITION,
CAT_ORDER_POSITION,
"voucher_code",
_("Voucher code"),
Question.TYPE_STRING,
None,
lambda position: position.voucher.code if position.voucher_id else "",
),
DataFieldInfo(
ORDER_POSITION,
CAT_ORDER_POSITION,
"ticket_id",
_("Order code and position number"),
Question.TYPE_STRING,
None,
lambda position: position.code,
),
DataFieldInfo(
ORDER_POSITION,
CAT_ORDER_POSITION,
"ticket_price",
_("Ticket price"),
Question.TYPE_NUMBER,
None,
lambda position: str(position.price),
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"order_status",
_("Order status"),
Question.TYPE_CHOICE,
Order.STATUS_CHOICE,
lambda order: [order.status],
),
DataFieldInfo(
ORDER_POSITION,
CAT_ORDER_POSITION,
"ticket_status",
_("Ticket status"),
Question.TYPE_CHOICE,
Order.STATUS_CHOICE,
lambda position: [Order.STATUS_CANCELED if position.canceled else position.order.status],
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"order_date",
_("Order date and time"),
Question.TYPE_DATETIME,
None,
lambda order: order.datetime.isoformat(),
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"payment_date",
_("Payment date and time"),
Question.TYPE_DATETIME,
None,
get_payment_date,
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"order_locale",
_("Order locale"),
Question.TYPE_CHOICE,
[(lc, lc) for lc in event.settings.locales],
lambda order: [order.locale],
),
DataFieldInfo(
ORDER_POSITION,
CAT_ORDER_POSITION,
"position_id",
_("Order position ID"),
Question.TYPE_NUMBER,
None,
lambda op: str(op.pk),
),
DataFieldInfo(
ORDER,
CAT_ORDER,
"presale_order_url",
_("Order link"),
Question.TYPE_STRING,
None,
lambda order: build_absolute_uri(
event,
'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret,
}
),
),
DataFieldInfo(
ORDER_POSITION,
CAT_ORDER_POSITION,
"presale_ticket_url",
_("Ticket link"),
Question.TYPE_STRING,
None,
lambda op: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': op.order.code,
'secret': op.web_secret,
'position': op.positionid
}
),
),
]
+ [
DataFieldInfo(
ORDER_POSITION,
CAT_ORDER_POSITION,
"checkin_date_" + str(cl.pk),
_("Check-in datetime on list {}").format(cl.name),
Question.TYPE_DATETIME,
None,
partial(first_checkin_on_list, cl.pk),
)
for cl in event.checkin_lists.all()
]
+ [
DataFieldInfo(
ORDER_POSITION,
CAT_QUESTIONS,
"question_" + q.identifier,
_("Question: {name}").format(name=str(q.question)),
q.type,
get_enum_opts(q),
partial(lambda qq, position: get_answer(position, qq.identifier), q),
)
for q in event.questions.filter(~Q(type=Question.TYPE_FILE)).prefetch_related("options")
]
)
if not any(field_name == "given_name" for field_name, label, weight in name_scheme["fields"]):
src_fields += [
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_name_given_name",
_("Attendee") + ": " + _("Given name") + " (⚠️ auto-generated, not recommended)",
Question.TYPE_STRING,
None,
lambda position: split_name_on_last_space(position.attendee_name, part=0),
deprecated=True,
),
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_name_given_name",
_("Invoice address") + ": " + _("Given name") + " (⚠️ auto-generated, not recommended)",
Question.TYPE_STRING,
None,
lambda order: split_name_on_last_space(get_invoice_address_or_empty(order).name, part=0),
deprecated=True,
),
]
if not any(field_name == "family_name" for field_name, label, weight in name_scheme["fields"]):
src_fields += [
DataFieldInfo(
ORDER_POSITION,
CAT_ATTENDEE,
"attendee_name_family_name",
_("Attendee") + ": " + _("Family name") + " (⚠️ auto-generated, not recommended)",
Question.TYPE_STRING,
None,
lambda position: split_name_on_last_space(position.attendee_name, part=1),
deprecated=True,
),
DataFieldInfo(
ORDER,
CAT_INVOICE_ADDRESS,
"invoice_address_name_family_name",
_("Invoice address") + ": " + _("Family name") + " (⚠️ auto-generated, not recommended)",
Question.TYPE_STRING,
None,
lambda order: split_name_on_last_space(get_invoice_address_or_empty(order).name, part=1),
deprecated=True,
),
]
if for_model:
available_inputs = AVAILABLE_MODELS[for_model]
return [
f for f in src_fields if f.required_input in available_inputs
]
else:
return src_fields
def get_enum_opts(q):
if q.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return [(opt.identifier, opt.answer) for opt in q.options.all()]
else:
return None

View File

@@ -0,0 +1,123 @@
#
# 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 typing import List, Tuple
from pretix.base.datasync.datasync import SyncConfigError
from pretix.base.models.datasync import (
MODE_APPEND_LIST, MODE_OVERWRITE, MODE_SET_IF_EMPTY, MODE_SET_IF_NEW,
)
def assign_properties(
new_values: List[Tuple[str, str, str]], old_values: dict, is_new, list_sep
):
"""
Generates a dictionary mapping property keys to new values, handling conditional overwrites and list updates
according to an update mode specified per property.
Supported update modes are:
- `MODE_OVERWRITE`: Replaces the existing value with the new value.
- `MODE_SET_IF_NEW`: Only sets the property if `is_new` is True.
- `MODE_SET_IF_EMPTY`: Only sets the property if the field is empty or missing in old_values.
- `MODE_APPEND_LIST`: Appends the new value to the list from old_values (or the empty list if missing),
using `list_sep` as a separator.
:param new_values: List of tuples, where each tuple contains (field_name, new_value, update_mode).
:param old_values: Dictionary, current property values in the external system.
:param is_new: Boolean, whether the object will be newly created in the external system.
:param list_sep: If string, used as a separator for MODE_APPEND_LIST. If None, native lists are used.
:raises SyncConfigError: If an invalid update mode is specified.
:returns: A dictionary containing the properties that need to be updated in the external system.
"""
out = {}
for field_name, new_value, update_mode in new_values:
if update_mode == MODE_OVERWRITE:
out[field_name] = new_value
continue
elif update_mode == MODE_SET_IF_NEW and not is_new:
continue
if not new_value:
continue
current_value = old_values.get(field_name, out.get(field_name, ""))
if update_mode in (MODE_SET_IF_EMPTY, MODE_SET_IF_NEW):
if not current_value:
out[field_name] = new_value
elif update_mode == MODE_APPEND_LIST:
_add_to_list(out, field_name, current_value, new_value, list_sep)
else:
raise SyncConfigError(["Invalid update mode " + update_mode])
return out
def _add_to_list(out, field_name, current_value, new_item, list_sep):
new_item = str(new_item)
if list_sep is not None:
new_item = new_item.replace(list_sep, "")
current_value = current_value.split(list_sep) if current_value else []
elif not isinstance(current_value, (list, tuple)):
current_value = [str(current_value)]
if new_item not in current_value:
new_list = current_value + [new_item]
if list_sep is not None:
new_list = list_sep.join(new_list)
out[field_name] = new_list
def translate_property_mappings(property_mappings, checkin_list_map):
"""
To properly handle copied events, users of data fields as provided by get_data_fields need to register to the
event_copy_data signal and translate all stored references to those fields using this method.
For example, if you store your mappings in a custom Django model with a ForeignKey to Event:
.. code-block:: python
@receiver(signal=event_copy_data, dispatch_uid="my_sync_event_copy_data")
def event_copy_data_receiver(sender, other, checkin_list_map, **kwargs):
object_mappings = other.my_object_mappings.all()
object_mapping_map = {}
for om in object_mappings:
om = copy.copy(om)
object_mapping_map[om.pk] = om
om.pk = None
om.event = sender
om.property_mappings = translate_property_mappings(om.property_mappings, checkin_list_map)
om.save()
"""
mappings = []
for mapping in property_mappings:
pretix_field = mapping["pretix_field"]
if pretix_field.startswith("checkin_date_"):
old_id = int(pretix_field[len("checkin_date_"):])
if old_id not in checkin_list_map:
# old_id might not be in checkin_list_map, because copying of an event series only copies check-in
# lists covering the whole series, not individual dates.
pretix_field = "_invalid_" + pretix_field
else:
pretix_field = "checkin_date_%d" % checkin_list_map[old_id].pk
mappings.append({**mapping, "pretix_field": pretix_field})
return mappings

View File

@@ -668,6 +668,7 @@ class OrderListExporter(MultiSheetListExporter):
_('External customer ID'),
_('Check-in lists'),
_('Payment providers'),
_('Position order link')
]
# get meta_data labels from first cached event
@@ -812,6 +813,14 @@ class OrderListExporter(MultiSheetListExporter):
if p and p != 'free'
]))
row.append(
build_absolute_uri(order.event, 'presale:event.order.position', kwargs={
'order': order.code,
'secret': op.web_secret,
'position': op.positionid
})
)
if has_subevents:
if op.subevent:
row += op.subevent.meta_data.values()

View File

@@ -54,7 +54,6 @@ from django.core.validators import (
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -78,6 +77,7 @@ from pretix.base.forms.widgets import (
from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language,
)
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import (
@@ -736,7 +736,7 @@ class BaseQuestionsForm(forms.Form):
initial=country,
widget=forms.Select(attrs={
'autocomplete': 'country',
'data-country-information-url': reverse('js_helpers.states'),
'data-trigger-address-info': 'on',
}),
)
c = [('', '---')]
@@ -1142,11 +1142,19 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if (not kwargs.get('instance') or not kwargs['instance'].country) and not kwargs["initial"].get("country"):
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
if kwargs.get('instance'):
kwargs['initial'].update(kwargs['instance'].transmission_info or {})
kwargs['initial']['transmission_type'] = kwargs['instance'].transmission_type
super().__init__(*args, **kwargs)
# Individuals do not have a company name or VAT ID
self.fields["company"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
self.fields["vat_id"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
# The internal reference is a very business-specific field and might confuse non-business users
self.fields["internal_reference"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
if not self.ask_vat_id:
del self.fields['vat_id']
elif self.validate_vat_id:
@@ -1162,8 +1170,17 @@ class BaseInvoiceAddressForm(forms.ModelForm):
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
])
transmission_type_choices = [
(t.identifier, t.public_name) for t in get_transmission_types()
]
if not self.address_required or self.all_optional:
transmission_type_choices.insert(0, ("-", _("No invoice requested")))
self.fields['transmission_type'] = forms.ChoiceField(
label=_('Invoice transmission method'),
choices=transmission_type_choices
)
self.fields['country'].choices = CachedCountries()
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
c = [('', '---')]
fprefix = self.prefix + '-' if self.prefix else ''
@@ -1242,6 +1259,44 @@ class BaseInvoiceAddressForm(forms.ModelForm):
else:
del self.fields['custom_field']
# Add transmission type specific fields
for transmission_type in get_transmission_types():
for k, f in transmission_type.invoice_address_form_fields.items():
if (
transmission_type.identifier == "email" and
k in ("transmission_email_other", "transmission_email_address") and
(
event.settings.invoice_generate == "False" or
not event.settings.invoice_email_attachment
)
):
# This looks like a very unclean hack (and probably really is one), but hear me out:
# With pretix 2025.7, we introduced invoice transmission types and added the "send to another email"
# feature for the email provider. This feature was previously part of the bank transfer payment
# provider and opt-in. With this change, this feature becomes available for all pretix shops, which
# we think is a good thing in the long run as it is an useful feature for every business customer.
# However, there's two scenarios where it might be bad that we add it without opt-in:
# - When the organizer has turned off invoice generation in pretix and is collecting invoice information
# only for other reasons or to later create invoices with a separate software. In this case it
# would be very bad for the user to be able to ask for the invoice to be sent somewhere else, and
# that information then be ignored because the organizer has not updated their process.
# - When the organizer has intentionally turned off invoices being attached to emails, because that
# would somehow be a contradiction.
# Now, the obvious solution would be to make the TransmissionType.invoice_address_form_fields property
# a function that depends on the event as an input. However, I believe this is the wrong approach
# over the long term. As a generalized concept, we DO want invoice address collection to be
# *independent* of event settings, in order to (later) e.g. implement invoice address editing within
# customer accounts. Hence, this hack directly in the form to provide (some) backwards compatibility
# only for the default transmission type "email".
continue
self.fields[k] = f
f._required = f.required
f.required = False
f.widget.is_required = False
if 'required' in f.widget.attrs:
del f.widget.attrs['required']
for k, v in self.fields.items():
if v.widget.attrs.get('autocomplete') or k == 'name_parts':
autocomplete = v.widget.attrs.get('autocomplete', '')
@@ -1250,6 +1305,10 @@ class BaseInvoiceAddressForm(forms.ModelForm):
else:
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + autocomplete
self.fields['country'].widget.attrs['data-trigger-address-info'] = 'on'
self.fields['is_business'].widget.attrs['data-trigger-address-info'] = 'on'
self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on'
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
@@ -1277,11 +1336,23 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.name_parts = data.get('name_parts')
if all(
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
) and name_parts_is_empty(data.get('name_parts', {})):
form_is_empty = all(
not v for k, v in data.items()
if k not in ('is_business', 'country', 'name_parts', 'transmission_type') and not k.startswith("transmission_")
) and name_parts_is_empty(data.get('name_parts', {}))
if form_is_empty:
# Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = ''
if data.get('transmission_type') == "-":
data['transmission_type'] = 'email' # our actual default for now, we can revisit this later
else:
if data.get('transmission_type') == "-":
raise ValidationError(
{"transmission_type": _("If you enter an invoice address, you also need to select an invoice "
"transmission method.")}
)
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
@@ -1303,6 +1374,37 @@ class BaseInvoiceAddressForm(forms.ModelForm):
else:
self.instance.vat_id_validated = False
for transmission_type in get_transmission_types():
if transmission_type.identifier == data.get("transmission_type"):
if not transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": _("The selected transmission type is not available in your country or for "
"your type of address.")
})
required_fields = transmission_type.invoice_address_form_fields_required(data.get("country"), data.get("is_business"))
for r in required_fields:
if r not in self.fields:
logger.info(f"Transmission type {transmission_type.identifier} required field {r} which is not available.")
raise ValidationError(
_("The selected type of invoice transmission requires a field that is currently not "
"available, please reach out to the organizer.")
)
if not data.get(r):
raise ValidationError({r: _("This field is required for the selected type of invoice transmission.")})
self.instance.transmission_type = transmission_type.identifier
self.instance.transmission_info = {
k: data.get(k) for k in transmission_type.invoice_address_form_fields
}
elif transmission_type.exclusive:
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
transmission_type.public_name,
)
})
class BaseInvoiceNameForm(BaseInvoiceAddressForm):
def __init__(self, *args, **kwargs):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
#
# 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/>.
#

View File

@@ -0,0 +1,173 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import forms
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
from i18nfield.strings import LazyI18nString
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import (
TransmissionProvider, TransmissionType, transmission_providers,
transmission_types,
)
from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.helpers.format import format_map
@transmission_types.new()
class EmailTransmissionType(TransmissionType):
identifier = "email"
verbose_name = _("Email")
priority = 1000
@property
def invoice_address_form_fields(self) -> dict:
return {
"transmission_email_other": forms.BooleanField(
label=_("Email invoice directly to accounting department"),
help_text=_("If not selected, the invoice will be sent to you using the email address listed above."),
required=False,
),
"transmission_email_address": forms.EmailField(
label=_("Email address for invoice"),
widget=forms.EmailInput(
attrs={"data-display-dependency": "#id_transmission_email_other"}
)
)
}
def invoice_address_form_fields_visible(self, country: Country, is_business: bool):
if is_business:
# We don't want ask non-business users if they have an accounting department ;)
return {"transmission_email_other", "transmission_email_address"}
return set()
def is_available(self, event, country: Country, is_business: bool):
# Skip availability check since provider is always available and we do not want to end up without invoice
# transmission type
return True
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return {
"transmission_email_other": bool(transmission_info.get("transmission_email_address")),
"transmission_email_address": transmission_info.get("transmission_email_address"),
}
def form_data_to_transmission_info(self, form_data: dict) -> dict:
if form_data.get("transmission_email_other") and form_data.get("transmission_email_address"):
return {
"transmission_email_address": form_data["transmission_email_address"],
}
return {}
@transmission_providers.new()
class EmailTransmissionProvider(TransmissionProvider):
identifier = "email_pdf"
type = "email"
verbose_name = _("PDF via email")
priority = 1000
testmode_supported = True
def is_ready(self, event) -> bool:
return True
def is_available(self, event, country: Country, is_business: bool) -> bool:
return True
def transmit(self, invoice: Invoice):
info = (invoice.invoice_to_transmission_info or {})
if info.get("transmission_email_address"):
recipient = info["transmission_email_address"]
else:
recipient = invoice.order.email
if not recipient:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": "email_pdf",
"transmission_type": "email",
"data": {
"reason": "no_recipient",
},
}
)
return
with language(invoice.order.locale, invoice.order.event.settings.region):
context = get_email_context(
event=invoice.order.event,
order=invoice.order,
invoice=invoice,
event_or_subevent=invoice.order.event,
invoice_address=getattr(invoice.order, 'invoice_address', None) or InvoiceAddress()
)
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
try:
# Do not set to completed because that is done by the email sending task
subject = format_map(subject, context)
email_content = render_mail(template, context)
mail(
[recipient],
subject,
template,
context=context,
event=invoice.order.event,
locale=invoice.order.locale,
order=invoice.order,
invoices=[invoice],
attach_tickets=False,
auto_email=True,
attach_ical=False,
plain_text_only=True,
no_order_links=True,
)
except SendMailException:
raise
else:
invoice.order.log_action(
'pretix.event.order.email.invoice',
user=None,
auth=None,
data={
'subject': subject,
'message': email_content,
'position': None,
'recipient': recipient,
'invoices': [invoice.pk],
'attach_tickets': False,
'attach_ical': False,
'attach_other_files': [],
'attach_cached_files': [],
}
)

View File

@@ -0,0 +1,75 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import forms
from django.core.validators import RegexValidator
from django.utils.translation import pgettext_lazy
from django_countries.fields import Country
from localflavor.it.forms import ITSocialSecurityNumberField
from pretix.base.invoicing.transmission import (
TransmissionType, transmission_types,
)
@transmission_types.new()
class ItalianSdITransmissionType(TransmissionType):
identifier = "it_sdi"
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
exclusive = True
enforce_transmission = True
def is_available(self, event, country: Country, is_business: bool):
return str(country) == "IT" and super().is_available(event, country, is_business)
@property
def invoice_address_form_fields(self) -> dict:
return {
"transmission_it_sdi_codice_fiscale": ITSocialSecurityNumberField(
label=pgettext_lazy("italian_invoice", "Fiscal code"),
required=False,
),
"transmission_it_sdi_pec": forms.EmailField(
label=pgettext_lazy("italian_invoice", "Address for certified electronic mail"),
widget=forms.EmailInput()
),
"transmission_it_sdi_recipient_code": forms.CharField(
label=pgettext_lazy("italian_invoice", "Recipient code"),
validators=[
RegexValidator("^[A-Z0-9]{6,7}$")
]
),
}
def invoice_address_form_fields_visible(self, country: Country, is_business: bool):
if is_business:
return {"transmission_it_sdi_codice_fiscale", "transmission_it_sdi_pec", "transmission_it_sdi_recipient_code"}
return {"transmission_it_sdi_codice_fiscale", "transmission_it_sdi_pec"}
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
base = {
"street", "zipcode", "city", "state", "country",
}
if is_business:
return base | {"company", "vat_id", "transmission_it_sdi_pec", "transmission_it_sdi_recipient_code"}
return base | {"transmission_it_sdi_codice_fiscale"}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import re
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
from pretix.base.invoicing.transmission import (
TransmissionType, transmission_types,
)
class PeppolIdValidator:
regex_rules = {
# Source: https://docs.peppol.eu/edelivery/codelists/old/v8.5/Peppol%20Code%20Lists%20-%20Participant%20identifier%20schemes%20v8.5.html
"0002": "[0-9]{9}([0-9]{5})?",
"0007": "[0-9]{10}",
"0009": "[0-9]{14}",
"0037": "(0037)?[0-9]{7}-?[0-9][0-9A-Z]{0,5}",
"0060": "[0-9]{9}",
"0088": "[0-9]{13}",
"0096": "[0-9]{17}",
"0097": "[0-9]{11,16}",
"0106": "[0-9]{17}",
"0130": ".*",
"0135": ".*",
"0142": ".*",
"0151": "[0-9]{11}",
"0183": "CHE[0-9]{9}",
"0184": "DK[0-9]{8}([0-9]{2})?",
"0188": ".*",
"0190": "[0-9]{20}",
"0191": "[1789][0-9]{7}",
"0192": "[0-9]{9}",
"0193": ".{4,50}",
"0195": "[a-z]{2}[a-z]{3}([0-9]{8}|[0-9]{9}|[RST][0-9]{2}[a-z]{2}[0-9]{4})[0-9a-z]",
"0196": "[0-9]{10}",
"0198": "DK[0-9]{8}",
"0199": "[A-Z0-9]{18}[0-9]{2}",
"0020": "[0-9]{9}",
"0201": "[0-9a-zA-Z]{6}",
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
"0208": "0[0-9]{9}",
"0209": ".*",
"0210": "[A-Z0-9]+",
"0211": "IT[0-9]{11}",
"0212": "[0-9]{7}-[0-9]",
"0213": "FI[0-9]{8}",
"0205": "[A-Z0-9]+",
"0221": "T[0-9]{13}",
"0230": ".*",
"9901": ".*",
"9902": "[1-9][0-9]{7}",
"9904": "DK[0-9]{8}",
"9909": "NO[0-9]{9}MVA",
"9910": "HU[0-9]{8}",
"9912": "[A-Z]{2}[A-Z0-9]{,20}",
"9913": ".*",
"9914": "ATU[0-9]*",
"9915": "[A-Z][A-Z0-9]*",
"9916": ".*",
"9917": "[0-9]{10}",
"9918": "[A-Z]{2}[0-9]{2}[A-Z-0-9]{11,30}",
"9919": "[A-Z][0-9]{3}[A-Z][0-9]{3}[A-Z]",
"9920": ".*",
"9921": ".*",
"9922": ".*",
"9923": ".*",
"9924": ".*",
"9925": ".*",
"9926": ".*",
"9927": ".*",
"9928": ".*",
"9929": ".*",
"9930": ".*",
"9931": ".*",
"9932": ".*",
"9933": ".*",
"9934": ".*",
"9935": ".*",
"9936": ".*",
"9937": ".*",
"9938": ".*",
"9939": ".*",
"9940": ".*",
"9941": ".*",
"9942": ".*",
"9943": ".*",
"9944": ".*",
"9945": ".*",
"9946": ".*",
"9947": ".*",
"9948": ".*",
"9949": ".*",
"9950": ".*",
"9951": ".*",
"9952": ".*",
"9953": ".*",
"9954": ".*",
"9956": "0[0-9]{9}",
"9957": ".*",
"9959": ".*",
}
def __call__(self, value):
if ":" not in value:
raise ValidationError(_("A PEPPOL participant ID always starts with a prefix, followed by a colon (:)."))
prefix, second = value.split(":", 1)
if prefix not in self.regex_rules:
raise ValidationError(_("The PEPPOL participant ID prefix %(number)s is not known to our system. Please "
"reach out to us if you are sure this ID is correct."), params={"number": prefix})
if not re.match(self.regex_rules[prefix], second):
raise ValidationError(_("The PEPPOL participant ID does not match the validation rules for the prefix "
"%(number)s. Please reach out to us if you are sure this ID is correct."),
params={"number": prefix})
return value
@transmission_types.new()
class PeppolTransmissionType(TransmissionType):
identifier = "peppol"
verbose_name = "PEPPOL"
priority = 250
enforce_transmission = True
def is_available(self, event, country: Country, is_business: bool):
return is_business and super().is_available(event, country, is_business)
@property
def invoice_address_form_fields(self) -> dict:
return {
"transmission_peppol_participant_id": forms.CharField(
label=_("PEPPOL participant ID"),
validators=[
PeppolIdValidator(),
]
),
}
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
base = {
"company", "street", "zipcode", "city", "country",
}
return base | {"transmission_peppol_participant_id"}

View File

@@ -0,0 +1,246 @@
#
# 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 typing import Optional
from django_countries.fields import Country
from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.signals import EventPluginRegistry, Registry
class TransmissionType:
@property
def identifier(self) -> str:
"""
A short and unique identifier for this transmission type.
"""
raise NotImplementedError
@property
def verbose_name(self) -> str:
"""
A human-readable name for this transmission type to be shown internally in the backend.
"""
raise NotImplementedError
@property
def public_name(self) -> str:
"""
A human-readable name for this transmission type to be shown to the public.
By default, this is the same as ``verbose_name``
"""
return self.verbose_name
@property
def priority(self) -> int:
"""
Returns a priority that is used for sorting transmission type. Higher priority means higher up in the list.
Default to 100. Providers with same priority are sorted alphabetically.
"""
return 100
@property
def exclusive(self) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction.
"""
return False
@property
def enforce_transmission(self) -> bool:
"""
If a transmission type enforces transmission, every invoice created with this type will be transferred.
If not, the backend user is in some cases trusted to decide whether or not to transmit it.
"""
return False
def is_available(self, event, country: Country, is_business: bool) -> bool:
providers = transmission_providers.filter(type=self.identifier, active_in=event)
return any(
provider.is_available(event, country, is_business)
for provider, _ in providers
)
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
return set()
def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set:
return set(self.invoice_address_form_fields.keys())
def validate_address(self, ia: InvoiceAddress):
pass
@property
def invoice_address_form_fields(self) -> dict:
"""
Return a set of form fields that **must** be prefixed with ``transmission_<identifier>_``.
"""
return {}
def form_data_to_transmission_info(self, form_data: dict) -> dict:
return form_data
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info
class TransmissionProvider:
"""
Base class for a transmission provider. Should NOT hold internal state as the class is only
instantiated once and then shared between events and organizers.
"""
@property
def identifier(self):
"""
A short and unique identifier for this transmission provider.
This should only contain lowercase letters and underscores.
"""
raise NotImplementedError
@property
def type(self):
"""
Identifier of the transmission type this provider provides.
"""
raise NotImplementedError
@property
def verbose_name(self):
"""
A human-readable name for this transmission provider (can be localized).
"""
raise NotImplementedError
@property
def testmode_supported(self) -> bool:
"""
Whether testmode invoices may be passed to this provider.
"""
return False
def is_ready(self, event) -> bool:
"""
Return whether this provider has all required configuration to be used in this event.
"""
raise NotImplementedError
def is_available(self, event, country: Country, is_business: bool) -> bool:
"""
Return whether this provider may be used for an invoice for the given recipient country and address type.
"""
raise NotImplementedError
def transmit(self, invoice: Invoice):
"""
Transmit the invoice. The invoice passed as a parameter will be in status ``TRANSMISSION_STATUS_INFLIGHT``.
Invoices that stay in this state for more than 24h will be retried automatically. Implementations are expected to:
- Send the invoice.
- Update the ``transmission_status`` to `TRANSMISSION_STATUS_COMPLETED` or `TRANSMISSION_STATUS_FAILED`
after sending, as well as ``transmission_info`` with provider-specific data, and ``transmission_date`` to
the date and time of completion.
- Create a log entry of action type ``pretix.event.order.invoice.sent`` or
``pretix.event.order.invoice.sending_failed`` with the fields ``full_invoice_no``, ``transmission_provider``,
``transmission_type`` and a provider-specific ``data`` field.
Make sure to either handle ``invoice.order.testmode`` properly or set ``testmode_supported`` to ``False``.
"""
raise NotImplementedError
@property
def priority(self) -> int:
"""
Returns a priority that is used for sorting transmission providers. Higher priority will be chosen over
lower priority for transmission. Default to 100.
"""
return 100
def settings_url(self, event) -> Optional[str]:
"""
Return a URL to the settings page of this provider (if any).
"""
return None
class TransmissionProviderRegistry(EventPluginRegistry):
def __init__(self):
super().__init__({
'identifier': lambda o: getattr(o, 'identifier'),
'type': lambda o: getattr(o, 'type'),
})
def register(self, *objs):
for obj in objs:
if not isinstance(obj, TransmissionProvider):
raise TypeError('Entries must be derived from TransmissionProvider')
if obj.type == "email" and not obj.__module__.startswith('pretix.base.'):
raise TypeError('No custom providers for email allowed')
return super().register(*objs)
class TransmissionTypeRegistry(Registry):
def __init__(self):
super().__init__({
'identifier': lambda o: getattr(o, 'identifier'),
})
def register(self, *objs):
for obj in objs:
if not isinstance(obj, TransmissionType):
raise TypeError('Entries must be derived from TransmissionType')
if not obj.__module__.startswith('pretix.base.'):
raise TypeError('Plugins are currently not allowed to add transmission types')
return super().register(*objs)
"""
Registry for transmission providers.
Each entry in this registry should be an instance of a subclass of ``TransmissionProvider``.
They are annotated with their ``identifier``, ``type``, and the defining ``plugin``.
"""
transmission_providers = TransmissionProviderRegistry()
"""
Registry for transmission types.
Each entry in this registry should be an instance of a subclass of ``TransmissionType``.
They are annotated with their ``identifier``.
"""
transmission_types = TransmissionTypeRegistry()
def get_transmission_types():
return sorted(
transmission_types.registered_entries.keys(),
key=lambda t: (-t.priority, str(t.public_name)),
)

View File

@@ -26,7 +26,7 @@ from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.signals import EventPluginRegistry
from pretix.base.signals import PluginAwareRegistry
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
@@ -55,7 +55,7 @@ def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
return format_html(wrapper, **a_map)
class LogEntryTypeRegistry(EventPluginRegistry):
class LogEntryTypeRegistry(PluginAwareRegistry):
def __init__(self):
super().__init__({'action_type': lambda o: getattr(o, 'action_type')})

View File

@@ -0,0 +1,54 @@
# Generated by Django 4.2.21 on 2025-06-27 13:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0283_taxrule_default_taxrule_backfill'),
]
operations = [
migrations.CreateModel(
name='OrderSyncResult',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('sync_provider', models.CharField(max_length=128)),
('mapping_id', models.IntegerField()),
('external_object_type', models.CharField(max_length=128)),
('external_id_field', models.CharField(max_length=128)),
('id_value', models.CharField(max_length=128)),
('external_link_href', models.CharField(max_length=255, null=True)),
('external_link_display_name', models.CharField(max_length=255, null=True)),
('transmitted', models.DateTimeField(auto_now_add=True)),
('sync_info', models.JSONField()),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sync_results', to='pretixbase.order')),
('order_position', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sync_results', to='pretixbase.orderposition')),
],
options={
'indexes': [models.Index(fields=['order', 'sync_provider'], name='pretixbase__order_i_3e3c84_idx')],
},
),
migrations.CreateModel(
name='OrderSyncQueue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('sync_provider', models.CharField(max_length=128)),
('triggered_by', models.CharField(max_length=128)),
('triggered', models.DateTimeField(auto_now_add=True)),
('failed_attempts', models.PositiveIntegerField(default=0)),
('not_before', models.DateTimeField(db_index=True)),
('need_manual_retry', models.CharField(null=True, max_length=20)),
('in_flight', models.BooleanField(default=False)),
('in_flight_since', models.DateTimeField(blank=True, null=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queued_sync_jobs', to='pretixbase.event')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queued_sync_jobs', to='pretixbase.order')),
],
options={
'ordering': ('triggered',),
'unique_together': {('order', 'sync_provider', 'in_flight')},
},
),
]

View File

@@ -0,0 +1,46 @@
# Generated by Django 4.2.16 on 2025-08-08 09:13
from django.db import migrations, models
from django.db.models import Min
from django.utils.timezone import now
def backfill_voucher_created(apps, schema_editor):
Voucher = apps.get_model("pretixbase", "Voucher")
LogEntry = apps.get_model("pretixbase", "LogEntry")
ContentType = apps.get_model("contenttypes", "ContentType")
ct = None
for v in Voucher.objects.filter(created__isnull=True).iterator():
if not ct:
# "Lazy-loading" to prevent this to be executed on new DBs where the content type does not yet
# exist -- but also no vouchers do
ct = ContentType.objects.get(app_label='pretixbase', model='voucher')
v.created = LogEntry.objects.filter(
content_type=ct,
object_id=v.pk,
).aggregate(m=Min("datetime"))["m"] or now()
v.save(update_fields=["created"])
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0284_ordersyncresult_ordersyncqueue"),
]
operations = [
migrations.AddField(
model_name="voucher",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.RunPython(
backfill_voucher_created,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name="voucher",
name="created",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.16 on 2025-08-14 09:40
from django.db import migrations
from hierarkey.utils import CleanHierarkeyDuplicates
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0285_voucher_created"),
]
operations = [
CleanHierarkeyDuplicates("GlobalSettingsObject_SettingsStore"),
CleanHierarkeyDuplicates("Organizer_SettingsStore"),
CleanHierarkeyDuplicates("Event_SettingsStore"),
migrations.AlterUniqueTogether(
name="event_settingsstore",
unique_together={("object", "key")},
),
migrations.AlterUniqueTogether(
name="globalsettingsobject_settingsstore",
unique_together={("key",)},
),
migrations.AlterUniqueTogether(
name="organizer_settingsstore",
unique_together={("object", "key")},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.17 on 2025-07-12 09:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0286_settingsstore_unique"),
]
operations = [
migrations.AddField(
model_name="organizer",
name="plugins",
field=models.TextField(default=""),
),
]

View File

@@ -0,0 +1,75 @@
# Generated by Django 4.2.17 on 2025-04-21 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0287_organizer_plugins"),
]
operations = [
migrations.RenameField(
model_name="invoice",
old_name="sent_to_customer",
new_name="transmission_date",
),
migrations.AddField(
model_name="invoice",
name="invoice_to_transmission_info",
field=models.JSONField(null=True),
),
migrations.AddField(
model_name="invoice",
name="transmission_info",
field=models.JSONField(null=True),
),
migrations.AddField(
model_name="invoice",
name="transmission_provider",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name="invoice",
name="transmission_status",
field=models.CharField(default="unknown", max_length=255),
),
migrations.AddField(
model_name="invoice",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="invoice",
name="invoice_to_is_business",
field=models.BooleanField(null=True),
),
migrations.RunSQL(
"UPDATE pretixbase_invoice SET transmission_status = 'completed' WHERE transmission_date IS NOT NULL",
migrations.RunSQL.noop,
),
migrations.AddField(
model_name="invoice",
name="transmission_type",
field=models.CharField(default="email", max_length=255),
),
migrations.AddField(
model_name="invoiceaddress",
name="transmission_info",
field=models.JSONField(null=True),
),
migrations.AddField(
model_name="invoiceaddress",
name="transmission_type",
field=models.CharField(default="email", max_length=255),
),
migrations.RunSQL(
"UPDATE pretixbase_event_settingsstore SET key = 'mail_text_order_invoice' WHERE key = 'payment_banktransfer_invoice_email_text'",
"UPDATE pretixbase_event_settingsstore SET key = 'payment_banktransfer_invoice_email_text' WHERE key = 'mail_text_order_invoice'",
),
migrations.RunSQL(
"UPDATE pretixbase_event_settingsstore SET key = 'mail_subject_order_invoice' WHERE key = 'payment_banktransfer_invoice_email_subject'",
"UPDATE pretixbase_event_settingsstore SET key = 'payment_banktransfer_invoice_email_subject' WHERE key = 'mail_subject_order_invoice'",
),
]

View File

@@ -350,6 +350,7 @@ class Checkin(models.Model):
REASON_BLOCKED = 'blocked'
REASON_UNAPPROVED = 'unapproved'
REASON_INVALID_TIME = 'invalid_time'
REASON_ANNULLED = 'annulled'
REASONS = (
(REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')),
@@ -364,6 +365,7 @@ class Checkin(models.Model):
(REASON_BLOCKED, _('Ticket blocked')),
(REASON_UNAPPROVED, _('Order not approved')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
(REASON_ANNULLED, _('Check-in annulled')),
)
successful = models.BooleanField(

View File

@@ -0,0 +1,149 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
from functools import cached_property
from django.db import IntegrityError, models
from django.utils.translation import gettext as _
from pretix.base.models import Event, Order, OrderPosition
logger = logging.getLogger(__name__)
MODE_OVERWRITE = "overwrite"
MODE_SET_IF_NEW = "if_new"
MODE_SET_IF_EMPTY = "if_empty"
MODE_APPEND_LIST = "append"
class OrderSyncQueue(models.Model):
order = models.ForeignKey(
Order, on_delete=models.CASCADE, related_name="queued_sync_jobs"
)
event = models.ForeignKey(
Event, on_delete=models.CASCADE, related_name="queued_sync_jobs"
)
sync_provider = models.CharField(blank=False, null=False, max_length=128)
triggered_by = models.CharField(blank=False, null=False, max_length=128)
triggered = models.DateTimeField(blank=False, null=False, auto_now_add=True)
failed_attempts = models.PositiveIntegerField(default=0)
not_before = models.DateTimeField(blank=False, null=False, db_index=True)
need_manual_retry = models.CharField(blank=True, null=True, max_length=20, choices=[
('exceeded', _('Temporary error, auto-retry limit exceeded')),
('permanent', _('Provider reported a permanent error')),
('config', _('Misconfiguration, please check provider settings')),
('internal', _('System error, needs manual intervention')),
('timeout', _('System error, needs manual intervention')),
])
in_flight = models.BooleanField(default=False)
in_flight_since = models.DateTimeField(blank=True, null=True)
class Meta:
unique_together = (("order", "sync_provider", "in_flight"),)
ordering = ("triggered",)
@cached_property
def _provider_class_info(self):
from pretix.base.datasync.datasync import datasync_providers
return datasync_providers.get(identifier=self.sync_provider)
@property
def provider_class(self):
return self._provider_class_info[0]
@property
def provider_display_name(self):
return self.provider_class.display_name
@property
def is_provider_active(self):
return self._provider_class_info[1]
@property
def max_retry_attempts(self):
return self.provider_class.max_attempts
def set_sync_error(self, failure_mode, messages, full_message):
logger.exception(
f"Could not sync order {self.order.code} to {type(self).__name__} ({failure_mode})"
)
self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", {
"provider": self.sync_provider,
"error": messages,
"full_message": full_message,
})
self.need_manual_retry = failure_mode
self.clear_in_flight()
def clear_in_flight(self):
self.in_flight = False
self.in_flight_since = None
try:
self.save()
except IntegrityError:
# if setting in_flight=False fails due to UNIQUE constraint, just delete the current instance
self.delete()
class OrderSyncResult(models.Model):
order = models.ForeignKey(
Order, on_delete=models.CASCADE, related_name="sync_results"
)
sync_provider = models.CharField(blank=False, null=False, max_length=128)
order_position = models.ForeignKey(
OrderPosition, on_delete=models.CASCADE, related_name="sync_results", blank=True, null=True,
)
mapping_id = models.IntegerField(blank=False, null=False)
external_object_type = models.CharField(blank=False, null=False, max_length=128)
external_id_field = models.CharField(blank=False, null=False, max_length=128)
id_value = models.CharField(blank=False, null=False, max_length=128)
external_link_href = models.CharField(blank=True, null=True, max_length=255)
external_link_display_name = models.CharField(blank=True, null=True, max_length=255)
transmitted = models.DateTimeField(blank=False, null=False, auto_now_add=True)
sync_info = models.JSONField()
class Meta:
indexes = [
models.Index(fields=("order", "sync_provider")),
]
def external_link_html(self):
if not self.external_link_display_name:
return None
from pretix.base.datasync.datasync import datasync_providers
prov, meta = datasync_providers.get(identifier=self.sync_provider)
if prov:
return prov.get_external_link_html(self.order.event, self.external_link_href, self.external_link_display_name)
def to_result_dict(self):
return {
"position": self.order_position_id,
"object_type": self.external_object_type,
"external_id_field": self.external_id_field,
"id_value": self.id_value,
"external_link_href": self.external_link_href,
"external_link_display_name": self.external_link_display_name,
**self.sync_info,
}

View File

@@ -551,8 +551,7 @@ class Event(EventMixin, LoggedModel):
:type presale_end: datetime
:param location: venue
:type location: str
:param plugins: A comma-separated list of plugin names that are active for this
event.
:param plugins: A comma-separated list of plugin names that are active for this event.
:type plugins: str
:param has_subevents: Enable event series functionality
:type has_subevents: bool
@@ -1085,7 +1084,7 @@ class Event(EventMixin, LoggedModel):
s.save(force_insert=True)
valid_sales_channel_identifers = set(self.organizer.sales_channels.values_list("identifier", flat=True))
skip_settings = (
skip_settings = {
'ticket_secrets_pretix_sig1_pubkey',
'ticket_secrets_pretix_sig1_privkey',
# no longer used, but we still don't need to copy them
@@ -1093,7 +1092,10 @@ class Event(EventMixin, LoggedModel):
'presale_css_checksum',
'presale_widget_css_file',
'presale_widget_css_checksum',
)
} | {
# Some settings might already exist due to e.g. the timezone being special in the API
s.key for s in self.settings._objects.all()
}
settings_to_save = []
for s in other.settings._objects.all():
if s.key in skip_settings:
@@ -1390,7 +1392,7 @@ class Event(EventMixin, LoggedModel):
from pretix.base.plugins import get_all_plugins
return {
p.module: p for p in get_all_plugins(self)
p.module: p for p in get_all_plugins(event=self)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
@@ -1409,12 +1411,20 @@ class Event(EventMixin, LoggedModel):
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()):
"""
Adds a plugin to the list of plugins, calling its ``installed`` hook (if available).
It is the caller's responsibility to save the event object.
"""
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
"""
Adds a plugin to the list of plugins, calling its ``uninstalled`` hook (if available).
It is the caller's responsibility to save the event object.
"""
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)

View File

@@ -42,7 +42,7 @@ from django.db.models.functions import Cast
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import pgettext
from django.utils.translation import gettext_lazy as _, pgettext
from django_scopes import ScopedManager
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
@@ -110,6 +110,21 @@ class Invoice(models.Model):
:param file: The filename of the rendered invoice
:type file: File
"""
TRANSMISSION_STATUS_PENDING = "pending"
TRANSMISSION_STATUS_INFLIGHT = "inflight"
TRANSMISSION_STATUS_COMPLETED = "completed"
TRANSMISSION_STATUS_FAILED = "failed"
TRANSMISSION_STATUS_UNKNOWN = "unknown"
TRANSMISSION_STATUS_TESTMODE_IGNORED = "testmode_ignored"
TRANSMISSION_STATUS_CHOICES = (
(TRANSMISSION_STATUS_PENDING, _("pending transmission")),
(TRANSMISSION_STATUS_INFLIGHT, _("currently being transmitted")),
(TRANSMISSION_STATUS_COMPLETED, _("transmitted")),
(TRANSMISSION_STATUS_FAILED, _("failed")),
(TRANSMISSION_STATUS_UNKNOWN, _("unknown")),
(TRANSMISSION_STATUS_TESTMODE_IGNORED, _("not transmitted due to test mode")),
)
order = models.ForeignKey('Order', related_name='invoices', db_index=True, on_delete=models.CASCADE)
organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', related_name='invoices', db_index=True, on_delete=models.CASCADE)
@@ -131,6 +146,7 @@ class Invoice(models.Model):
invoice_to = models.TextField()
invoice_to_company = models.TextField(null=True)
invoice_to_is_business = models.BooleanField(null=True)
invoice_to_name = models.TextField(null=True)
invoice_to_street = models.TextField(null=True)
invoice_to_zipcode = models.CharField(max_length=190, null=True)
@@ -139,9 +155,11 @@ class Invoice(models.Model):
invoice_to_country = FastCountryField(null=True)
invoice_to_vat_id = models.TextField(null=True)
invoice_to_beneficiary = models.TextField(null=True)
invoice_to_transmission_info = models.JSONField(null=True, blank=True)
internal_reference = models.TextField(blank=True)
custom_field = models.CharField(max_length=255, null=True)
created = models.DateTimeField(auto_now_add=True, null=True) # null for backwards compatibility
date = models.DateField(default=today)
locale = models.CharField(max_length=50, default='en')
introductory_text = models.TextField(blank=True)
@@ -158,14 +176,28 @@ class Invoice(models.Model):
shredded = models.BooleanField(default=False)
# The field sent_to_organizer records whether this invocie was already sent to the organizer by a configured
# The field sent_to_organizer records whether this invoice was already sent to the organizer by a configured
# mechanism such as email.
# NULL: The cronjob that handles sending did not yet run.
# True: The invoice was sent.
# False: The invoice wasn't sent and never will, because sending was not configured at the time of the check.
sent_to_organizer = models.BooleanField(null=True, blank=True)
sent_to_customer = models.DateTimeField(null=True, blank=True)
transmission_type = models.CharField(
max_length=255,
default="email",
)
transmission_provider = models.CharField(
max_length=255,
null=True, blank=True,
)
transmission_status = models.CharField(
max_length=255,
choices=TRANSMISSION_STATUS_CHOICES,
default=TRANSMISSION_STATUS_UNKNOWN,
)
transmission_date = models.DateTimeField(null=True, blank=True)
transmission_info = models.JSONField(null=True, blank=True)
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
@@ -323,6 +355,19 @@ class Invoice(models.Model):
def __str__(self):
return self.full_invoice_no
@property
def regenerate_allowed(self):
return self.transmission_status in (
Invoice.TRANSMISSION_STATUS_UNKNOWN,
Invoice.TRANSMISSION_STATUS_PENDING,
Invoice.TRANSMISSION_STATUS_FAILED,
) and self.event.settings.invoice_regenerate_allowed
@property
def transmission_type_instance(self):
from pretix.base.invoicing.transmission import transmission_types
return transmission_types.get(identifier=self.transmission_type)[0]
class InvoiceLine(models.Model):
"""

View File

@@ -40,9 +40,6 @@ from django.contrib.contenttypes.models import ContentType
from django.db import connections, models
from django.utils.functional import cached_property
from pretix.base.logentrytype_registry import log_entry_types, make_link
from pretix.base.signals import is_app_active, logentry_object_link
class VisibleOnlyManager(models.Manager):
def get_queryset(self):
@@ -91,6 +88,8 @@ class LogEntry(models.Model):
indexes = [models.Index(fields=["datetime", "id"])]
def display(self):
from pretix.base.logentrytype_registry import log_entry_types
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
if log_entry_type:
return log_entry_type.display(self, self.parsed_data)
@@ -128,6 +127,11 @@ class LogEntry(models.Model):
@cached_property
def display_object(self):
from pretix.base.logentrytype_registry import (
log_entry_types, make_link,
)
from pretix.base.signals import is_app_active, logentry_object_link
from . import (
Discount, Event, Item, Order, Question, Quota, SubEvent, Voucher,
)

View File

@@ -1821,7 +1821,7 @@ class OrderPayment(models.Model):
def fail(self, info=None, user=None, auth=None, log_data=None, send_mail=True):
"""
Marks the order as failed and sets info to ``info``, but only if the order is in ``created`` or ``pending``
Marks the order as failed and sets info to ``info``, but only if the order is in ``created``, ``pending`` or ``canceled``
state. This is equivalent to setting ``state`` to ``OrderPayment.PAYMENT_STATE_FAILED`` and logging a failure,
but it adds strong database locking since we do not want to report a failure for an order that has just
been marked as paid.
@@ -1829,7 +1829,11 @@ class OrderPayment(models.Model):
"""
with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
if locked_instance.state not in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
if locked_instance.state in (
OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_FAILED,
OrderPayment.PAYMENT_STATE_REFUNDED
):
# Race condition detected, this payment is already confirmed
logger.info('Failed payment {} but ignored due to likely race condition.'.format(
self.full_id,
@@ -1935,6 +1939,7 @@ class OrderPayment(models.Model):
ignore_date=False, lock=True, payment_refund_sum=0, allow_generate_invoice=True):
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
invoice_transmission_separately, transmit_invoice,
)
from pretix.base.services.locking import LOCK_TRUST_WINDOW
@@ -1965,13 +1970,19 @@ class OrderPayment(models.Model):
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
)
transmit_invoice_task = invoice_transmission_separately(invoice)
transmit_invoice_mail = not transmit_invoice_task and self.order.event.settings.invoice_email_attachment and self.order.email
if send_mail and self.order.sales_channel.identifier in self.order.event.settings.mail_sales_channel_placed_paid:
self._send_paid_mail(invoice, user, mail_text)
self._send_paid_mail(invoice if transmit_invoice_mail else None, user, mail_text)
if self.order.event.settings.mail_send_order_paid_attendee:
for p in self.order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email:
self._send_paid_mail_attendee(p, user)
if invoice and not transmit_invoice_mail:
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
def _send_paid_mail_attendee(self, position, user):
from pretix.base.services.mail import SendMailException
@@ -2001,7 +2012,7 @@ class OrderPayment(models.Model):
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
invoices=[invoice] if invoice else [],
attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical
)
@@ -3289,6 +3300,9 @@ class InvoiceAddress(models.Model):
blank=True
)
transmission_type = models.CharField(max_length=255, default="email")
transmission_info = models.JSONField(null=True, blank=True)
objects = ScopedManager(organizer='order__event__organizer')
profiles = ScopedManager(organizer='customer__organizer')
@@ -3310,6 +3324,24 @@ class InvoiceAddress(models.Model):
kwargs['update_fields'] = {'name_cached', 'name_parts'}.union(kwargs['update_fields'])
super().save(**kwargs)
def clear(self, except_name=False):
self.is_business = False
if not except_name:
self.name_cached = ""
self.name_parts = {}
self.company = ""
self.street = ""
self.zipcode = ""
self.city = ""
self.country_old = ""
self.country = ""
self.state = ""
self.vat_id = ""
self.vat_id_validated = False
self.custom_field = None
self.internal_reference = ""
self.beneficiary = ""
def describe(self):
parts = [
self.company,
@@ -3322,6 +3354,7 @@ class InvoiceAddress(models.Model):
self.internal_reference,
(_('Beneficiary') + ': ' + self.beneficiary) if self.beneficiary else '',
]
parts += [f'{k}: {v}' for k, v in self.describe_transmission()]
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
@property
@@ -3376,9 +3409,28 @@ class InvoiceAddress(models.Model):
'custom_field': self.custom_field,
'internal_reference': self.internal_reference,
'beneficiary': self.beneficiary,
'transmission_type': self.transmission_type,
**(self.transmission_info or {}),
})
return d
def describe_transmission(self):
from pretix.base.invoicing.transmission import transmission_types
data = []
t, __ = transmission_types.get(identifier=self.transmission_type)
data.append((_("Transmission type"), t.public_name))
form_data = t.transmission_info_to_form_data(self.transmission_info or {})
for k, f in t.invoice_address_form_fields.items():
v = form_data.get(k)
if v is True:
v = _("Yes")
elif v is False:
v = _("No")
if v:
data.append((f.label, v))
return data
def cachedticket_name(instance, filename: str) -> str:
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)

View File

@@ -68,6 +68,8 @@ class Organizer(LoggedModel):
:param slug: A globally unique, short name for this organizer, to be used
in URLs and similar places.
:type slug: str
:param plugins: A comma-separated list of plugin names that are active for this organizer.
:type plugins: str
"""
settings_namespace = 'organizer'
@@ -91,6 +93,10 @@ class Organizer(LoggedModel):
verbose_name=_("Short form"),
unique=True
)
plugins = models.TextField(
verbose_name=_("Plugins"),
null=False, blank=True, default="",
)
class Meta:
verbose_name = _("Organizer")
@@ -119,6 +125,11 @@ class Organizer(LoggedModel):
"""
self.settings.cookie_consent = True
plugins = [p for p in settings.PRETIX_PLUGINS_ORGANIZER_DEFAULT.split(",") if p]
if plugins:
self.set_active_plugins(plugins, allow_restricted=plugins)
self.save()
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -143,6 +154,61 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self)
def get_plugins(self):
"""
Returns the names of the plugins activated for this organizer as a list.
"""
if not self.plugins:
return []
return self.plugins.split(",")
def get_available_plugins(self):
from pretix.base.plugins import get_all_plugins
return {
p.module: p for p in get_all_plugins(organizer=self)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
def set_active_plugins(self, modules, allow_restricted=frozenset()):
plugins_active = self.get_plugins()
plugins_available = self.get_available_plugins()
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()):
"""
Adds a plugin to the list of plugins, calling its ``installed`` hook (if available).
It is the caller's responsibility to save the organizer object.
"""
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
"""
Removes a plugin from the list of plugins, calling its ``uninstalled`` hook (if available).
It is the caller's responsibility to save the organizer object and, in case of a hybrid organizer-event plugin,
to remove it from all events.
"""
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
plugins_available = self.get_available_plugins()
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
@property
def timezone(self):
return pytz_deprecation_shim.timezone(self.settings.timezone)

View File

@@ -174,6 +174,9 @@ class Voucher(LoggedModel):
('percent', _('Reduce product price by (%)')),
)
created = models.DateTimeField(
auto_now_add=True,
)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,

View File

@@ -207,16 +207,19 @@ class WaitingListEntry(LoggedModel):
block_quota=True,
subevent=self.subevent,
)
v.log_action('pretix.voucher.added.waitinglist', {
v.log_action('pretix.voucher.added', {
'item': self.item.pk,
'variation': self.variation.pk if self.variation else None,
'tag': 'waiting-list',
'block_quota': True,
'valid_until': v.valid_until.isoformat(),
'max_usages': 1,
'subevent': self.subevent.pk if self.subevent else None,
'source': 'waitinglist',
}, user=user, auth=auth)
v.log_action('pretix.voucher.added.waitinglist', {
'email': self.email,
'waitinglistentry': self.pk,
'subevent': self.subevent.pk if self.subevent else None,
}, user=user, auth=auth)
self.voucher = v
self.save()

View File

@@ -28,8 +28,13 @@ import importlib_metadata as metadata
from django.apps import AppConfig, apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
from packaging.requirements import Requirement
PLUGIN_LEVEL_EVENT = 'event'
PLUGIN_LEVEL_ORGANIZER = 'organizer'
PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID = 'event_organizer'
class PluginType(Enum):
"""
@@ -43,11 +48,14 @@ class PluginType(Enum):
EXPORT = 4
def get_all_plugins(event=None) -> List[type]:
def get_all_plugins(*, event=None, organizer=None) -> List[type]:
"""
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
"""
assert not event or not organizer
plugins = []
event_fallback = None
event_fallback_used = False
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
meta = app.PretixPluginMeta
@@ -56,8 +64,26 @@ def get_all_plugins(event=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
if hasattr(app, 'is_available') and event:
if not app.is_available(event):
level = getattr(app, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'):
if not app.is_available(event):
continue
elif organizer and hasattr(app, 'is_available'):
if not event_fallback_used:
event_fallback = organizer.events.first()
event_fallback_used = True
if not event_fallback or not app.is_available(event_fallback):
continue
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer and hasattr(app, 'is_available'):
if not app.is_available(organizer):
continue
elif event and hasattr(app, 'is_available'):
if not app.is_available(event.organizer):
continue
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'):
if not app.is_available(event or organizer):
continue
plugins.append(meta)
@@ -91,3 +117,26 @@ class PluginConfig(AppConfig, metaclass=PluginConfigMeta):
self.name, req, requirement_version
))
sys.exit(1)
if not hasattr(self.PretixPluginMeta, 'level'):
self.PretixPluginMeta.level = PLUGIN_LEVEL_EVENT
if self.PretixPluginMeta.level not in (PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID):
raise ImproperlyConfigured(f"Unknown plugin level '{self.PretixPluginMeta.level}'")
CATEGORY_ORDER = [
'FEATURE',
'PAYMENT',
'INTEGRATION',
'CUSTOMIZATION',
'FORMAT',
'API',
]
CATEGORY_LABELS = {
'FEATURE': _('Features'),
'PAYMENT': _('Payment providers'),
'INTEGRATION': _('Integrations'),
'CUSTOMIZATION': _('Customizations'),
'FORMAT': _('Output and export formats'),
'API': _('API features'),
}

View File

@@ -0,0 +1,105 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
from datetime import timedelta
from itertools import groupby
from django.db.models import F, Window
from django.db.models.functions import RowNumber
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scope, scopes_disabled
from pretix.base.datasync.datasync import datasync_providers
from pretix.base.models.datasync import OrderSyncQueue
from pretix.base.signals import periodic_task
from pretix.celery_app import app
logger = logging.getLogger(__name__)
@receiver(periodic_task, dispatch_uid="data_sync_periodic_sync_all")
def periodic_sync_all(sender, **kwargs):
sync_all.apply_async()
@receiver(periodic_task, dispatch_uid="data_sync_periodic_reset_in_flight")
def periodic_reset_in_flight(sender, **kwargs):
for sq in OrderSyncQueue.objects.filter(
in_flight=True,
in_flight_since__lt=now() - timedelta(minutes=20),
):
sq.set_sync_error('timeout', [], 'Timeout')
def run_sync(queue):
grouped = groupby(sorted(queue, key=lambda q: (q.sync_provider, q.event.pk)), lambda q: (q.sync_provider, q.event))
for (target, event), queued_orders in grouped:
target_cls, meta = datasync_providers.get(identifier=target, active_in=event)
if not target_cls:
# sync plugin not found (plugin deactivated or uninstalled) -> drop outstanding jobs
num_deleted, _ = OrderSyncQueue.objects.filter(pk__in=[sq.pk for sq in queued_orders]).delete()
logger.info("Deleted %d queue entries from %r because plugin %s inactive", num_deleted, event, target)
continue
with scope(organizer=event.organizer):
with target_cls(event=event) as p:
p.sync_queued_orders(queued_orders)
@app.task()
def sync_all():
with scopes_disabled():
queue = (
OrderSyncQueue.objects
.filter(
in_flight=False,
not_before__lt=now(),
need_manual_retry__isnull=True,
)
.order_by(Window(
expression=RowNumber(),
partition_by=[F("event_id")],
order_by="not_before",
))
.prefetch_related("event")
[:1000]
)
run_sync(queue)
@app.task()
def sync_single(queue_item_id: int):
with scopes_disabled():
queue = (
OrderSyncQueue.objects
.filter(
pk=queue_item_id,
in_flight=False,
not_before__lt=now(),
need_manual_retry__isnull=True,
)
.prefetch_related("event")
)
run_sync(queue)

View File

@@ -51,11 +51,16 @@ from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_providers,
)
from pretix.base.models import (
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
)
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tasks import (
TransactionAwareProfiledEventTask, TransactionAwareTask,
)
from pretix.base.signals import invoice_line_text, periodic_task
from pretix.celery_app import app
from pretix.helpers.database import OF_SELF, rolledback_transaction
@@ -71,12 +76,13 @@ def _location_oneliner(loc):
@transaction.atomic
def build_invoice(invoice: Invoice) -> Invoice:
invoice.locale = invoice.event.settings.get('invoice_language', invoice.event.settings.locale)
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
if invoice.locale == '__user__':
invoice.locale = invoice.order.locale or invoice.event.settings.locale
lp = invoice.order.payments.last()
with language(invoice.locale, invoice.event.settings.region):
with (language(invoice.locale, invoice.event.settings.region)):
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
@@ -127,6 +133,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.internal_reference = ia.internal_reference
invoice.custom_field = ia.custom_field
invoice.invoice_to_company = ia.company
invoice.invoice_to_is_business = ia.is_business
invoice.invoice_to_name = ia.name
invoice.invoice_to_street = ia.street
invoice.invoice_to_zipcode = ia.zipcode
@@ -134,6 +141,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.invoice_to_country = ia.country
invoice.invoice_to_state = ia.state
invoice.invoice_to_beneficiary = ia.beneficiary
invoice.invoice_to_transmission_info = ia.transmission_info or {}
invoice.transmission_type = ia.transmission_type
if ia.vat_id:
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
@@ -356,7 +365,9 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation.payment_provider_stamp = ''
cancellation.file = None
cancellation.sent_to_organizer = None
cancellation.sent_to_customer = None
cancellation.transmission_provider = None
cancellation.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
cancellation.transmission_date = None
with language(invoice.locale, invoice.event.settings.region):
cancellation.invoice_from = invoice.event.settings.get('invoice_address_from')
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
@@ -512,6 +523,36 @@ def build_preview_invoice_pdf(event):
return event.invoice_renderer.generate(invoice)
def order_invoice_transmission_separately(order):
try:
info = order.invoice_address.transmission_info or {}
return (
order.invoice_address.transmission_type != "email" or
(
info.get("transmission_email_address") and
order.email != info["transmission_email_address"]
)
)
except InvoiceAddress.DoesNotExist:
return False
def invoice_transmission_separately(invoice):
if not invoice:
return False
try:
info = invoice.invoice_to_transmission_info or {}
return (
invoice.transmission_type != "email" or
(
info.get("transmission_email_address") and
invoice.order.email != info["transmission_email_address"]
)
)
except InvoiceAddress.DoesNotExist:
return False
@receiver(signal=periodic_task)
@scopes_disabled()
def send_invoices_to_organizer(sender, **kwargs):
@@ -551,3 +592,124 @@ def send_invoices_to_organizer(sender, **kwargs):
else:
i.sent_to_organizer = False
i.save(update_fields=['sent_to_organizer'])
@receiver(signal=periodic_task)
@scopes_disabled()
def retry_stuck_invoices(sender, **kwargs):
with transaction.atomic():
qs = Invoice.objects.filter(
transmission_status=Invoice.TRANSMISSION_STATUS_INFLIGHT,
transmission_date__lte=now() - timedelta(hours=24),
).select_for_update(
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
)
batch_size = 5000
for invoice in qs[:batch_size]:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
transmit_invoice.apply_async(args=(invoice.event_id, invoice.pk, True))
@receiver(signal=periodic_task)
@scopes_disabled()
def send_pending_invoices(sender, **kwargs):
with transaction.atomic():
# Transmit all invoices that have not been transmitted by another process if the provider enforces
# transmission
types = [
tt.identifier for tt in get_transmission_types()
if tt.enforce_transmission
]
qs = Invoice.objects.filter(
transmission_type__in=types,
transmission_status=Invoice.TRANSMISSION_STATUS_PENDING,
created__lte=now() - timedelta(minutes=15),
).select_for_update(
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
)
batch_size = 5000
for invoice in qs[:batch_size]:
transmit_invoice.apply_async(args=(invoice.event_id, invoice.pk, False))
@app.task(base=TransactionAwareProfiledEventTask)
def transmit_invoice(sender, invoice_id, allow_retransmission=True, **kwargs):
with transaction.atomic(durable='tests.testdummy' not in settings.INSTALLED_APPS):
# We need durable=True for transactional correctness, but can't have it during tests
invoice = Invoice.objects.select_for_update(of=OF_SELF).get(pk=invoice_id)
if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT:
logger.info(f"Did not transmit invoice {invoice.pk} due to being in inflight state.")
return
if invoice.transmission_status != Invoice.TRANSMISSION_STATUS_PENDING and not allow_retransmission:
logger.info(f"Did not transmit invoice {invoice.pk} due to status being {invoice.transmission_status}.")
return
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_INFLIGHT
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
providers = sorted([
provider
for provider, __ in transmission_providers.filter(type=invoice.transmission_type, active_in=sender)
], key=lambda p: (-p.priority, p.identifier))
provider = None
for p in providers:
if p.is_available(sender, invoice.invoice_to_country, invoice.invoice_to_is_business):
provider = p
break
if not provider:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": None,
"transmission_type": invoice.transmission_type,
"data": {
"reason": "no_provider",
},
}
)
return
if invoice.order.testmode and not provider.testmode_supported:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_TESTMODE_IGNORED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.testmode_ignored",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": None,
"transmission_type": invoice.transmission_type,
}
)
return
try:
provider.transmit(invoice)
except Exception as e:
logger.exception(f"Transmission of invoice {invoice.pk} failed with exception.")
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": None,
"transmission_type": invoice.transmission_type,
"data": {
"reason": "exception",
"exception": str(e),
},
}
)

View File

@@ -405,8 +405,12 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
attach_cid_images(html_message, cid_images, verify_ssl=True)
email.attach_alternative(html_message, "multipart/related")
log_target = None
if user:
user = User.objects.get(pk=user)
error_log_action_type = 'pretix.user.email.error'
log_target = user
if event:
with scopes_disabled():
@@ -426,12 +430,15 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
with cm():
if customer:
customer = Customer.objects.get(pk=customer)
log_target = user or customer
if not user:
error_log_action_type = 'pretix.customer.email.error'
log_target = customer
if event:
if order:
try:
order = event.orders.get(pk=order)
error_log_action_type = 'pretix.event.order.email.error'
log_target = order
except Order.DoesNotExist:
order = None
@@ -574,7 +581,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
except MaxRetriesExceededError:
if log_target:
log_target.log_action(
'pretix.email.error',
error_log_action_type,
data={
'subject': 'SMTP code {}, max retries exceeded'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
@@ -587,7 +594,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
logger.exception('Error sending email')
if log_target:
log_target.log_action(
'pretix.email.error',
error_log_action_type,
data={
'subject': 'SMTP code {}'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
@@ -618,7 +625,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
message.append(f'{e}: {val[0]} {val[1].decode()}')
log_target.log_action(
'pretix.email.error',
error_log_action_type,
data={
'subject': 'SMTP error',
'message': '\n'.join(message),
@@ -635,7 +642,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
except MaxRetriesExceededError:
if log_target:
log_target.log_action(
'pretix.email.error',
error_log_action_type,
data={
'subject': 'Internal error',
'message': f'Max retries exceeded after error "{str(e)}"',
@@ -646,7 +653,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
raise e
if log_target:
log_target.log_action(
'pretix.email.error',
error_log_action_type,
data={
'subject': 'Internal error',
'message': str(e),
@@ -658,8 +665,55 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
raise SendMailException('Failed to send an email to {}.'.format(to))
else:
for i in invoices_sent:
i.sent_to_customer = now()
i.save(update_fields=['sent_to_customer'])
if i.transmission_type == "email":
# Mark invoice as sent when it was sent to the requested address *either* at the time of invoice
# creation *or* as of right now.
expected_recipients = [
(i.invoice_to_transmission_info or {}).get("transmission_email_address") or i.order.email,
]
try:
expected_recipients.append((i.order.invoice_address.transmission_info or {}).get("transmission_email_address") or i.order.email)
except InvoiceAddress.DoesNotExist:
pass
if not any(t in expected_recipients for t in to):
continue
if i.transmission_status != Invoice.TRANSMISSION_STATUS_COMPLETED:
i.transmission_date = now()
i.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED
i.transmission_provider = "email_pdf"
i.transmission_info = {
"sent": [
{
"recipients": to,
"datetime": now().isoformat(),
}
]
}
i.save(update_fields=[
"transmission_date", "transmission_provider", "transmission_status",
"transmission_info"
])
elif i.transmission_provider == "email_pdf":
i.transmission_info["sent"].append(
{
"recipients": to,
"datetime": now().isoformat(),
}
)
i.save(update_fields=[
"transmission_info"
])
i.order.log_action(
"pretix.event.order.invoice.sent",
data={
"full_invoice_no": i.full_invoice_no,
"transmission_provider": "email_pdf",
"transmission_type": "email",
"data": {
"recipients": [to],
},
}
)
def mail_send(*args, **kwargs):

View File

@@ -84,6 +84,8 @@ from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
invoice_transmission_separately, order_invoice_transmission_separately,
transmit_invoice,
)
from pretix.base.services.locking import (
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
@@ -389,13 +391,19 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
order_approved.send(order.event, order=order)
invoice = order.invoices.last() # Might be generated by plugin already
transmit_invoice_task = order_invoice_transmission_separately(order)
transmit_invoice_mail = not transmit_invoice_task and order.event.settings.invoice_email_attachment and order.email
if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
if not invoice:
invoice = generate_invoice(
order,
trigger_pdf=not order.event.settings.invoice_email_attachment or not order.email
# send_mail will trigger PDF generation later
trigger_pdf=not transmit_invoice_mail
)
# send_mail will trigger PDF generation later
if transmit_invoice_task:
transmit_invoice.apply_async(args=(order.event_id, invoice.pk, False))
if send_mail:
with language(order.locale, order.event.settings.region):
@@ -423,7 +431,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
order.total == Decimal('0.00') or
order.valid_if_pending
),
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
invoices=[invoice] if invoice and transmit_invoice_mail else []
)
except SendMailException:
logger.exception('Order approved email could not be sent')
@@ -619,6 +627,11 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
order.create_transactions()
transmit_invoices_task = [i for i in invoices if invoice_transmission_separately(i)]
transmit_invoices_mail = [i for i in invoices if i not in transmit_invoices_task and order.event.settings.invoice_email_attachment]
for i in transmit_invoices_task:
transmit_invoice.apply_async(args=(order.event_id, i.pk, False))
if send_mail:
with language(order.locale, order.event.settings.region):
email_template = order.event.settings.mail_text_order_canceled
@@ -628,7 +641,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_canceled', user,
invoices=invoices if order.event.settings.invoice_email_attachment else []
invoices=transmit_invoices_mail,
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
@@ -1085,11 +1098,12 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
def _order_placed_email(event: Event, order: Order, email_template, subject_template,
log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
email_context = get_email_context(event=event, order=order, payments=payments)
try:
order.send_mail(
subject_template, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
invoices=[invoice] if invoice else [],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
@@ -1278,6 +1292,9 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
not order.require_approval
)
transmit_invoice_task = order_invoice_transmission_separately(order)
transmit_invoice_mail = not transmit_invoice_task and order.event.settings.invoice_email_attachment and order.email
invoice = order.invoices.last() # Might be generated by plugin already
if not invoice and invoice_qualified(order):
invoice_required = (
@@ -1291,9 +1308,11 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if invoice_required:
invoice = generate_invoice(
order,
trigger_pdf=not event.settings.invoice_email_attachment or not order.email
# send_mail will trigger PDF generation later
trigger_pdf=not transmit_invoice_mail
)
# send_mail will trigger PDF generation later
if transmit_invoice_task:
transmit_invoice.apply_async(args=(event.pk, invoice.pk, False))
if order.email:
if order.require_approval:
@@ -1320,8 +1339,16 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
subject_attendees_template = event.settings.mail_subject_order_placed_attendee
if sales_channel.identifier in event.settings.mail_sales_channel_placed_paid:
_order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs,
is_free=free_order_flow)
_order_placed_email(
event,
order,
email_template,
subject_template,
log_entry,
invoice if transmit_invoice_mail else None,
payment_objs,
is_free=free_order_flow
)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
@@ -2924,17 +2951,36 @@ class OrderChangeManager:
if self.split_order:
self.split_order.create_transactions()
transmit_invoices_task = [i for i in self._invoices if invoice_transmission_separately(i)]
transmit_invoices_mail = [
i for i in self._invoices
if i not in transmit_invoices_task and self.event.settings.invoice_email_attachment and self.order.email
]
if self.split_order:
split_invoices = list(self.split_order.invoices.all())
transmit_invoices_task += [
i for i in split_invoices if invoice_transmission_separately(i)
]
split_transmit_invoices_mail = [
i for i in split_invoices
if i not in transmit_invoices_task and self.event.settings.invoice_email_attachment and self.order.email
]
if self.notify:
notify_user_changed_order(
self.order, self.user, self.auth,
self._invoices if self.event.settings.invoice_email_attachment else []
transmit_invoices_mail,
)
if self.split_order:
notify_user_changed_order(
self.split_order, self.user, self.auth,
list(self.split_order.invoices.all()) if self.event.settings.invoice_email_attachment else []
split_transmit_invoices_mail,
)
for i in transmit_invoices_task:
transmit_invoice.apply_async(args=(self.event.pk, i.pk, False))
order_changed.send(self.order.event, order=self.order)
def _clear_tickets_cache(self):

View File

@@ -197,6 +197,7 @@ def order_overview(
item.all_variations = list(item.variations.all())
item.has_variations = (len(item.all_variations) > 0)
item.num = {}
item.subevent = subevent
if item.has_variations:
for var in item.all_variations:
variid = var.id

View File

@@ -114,7 +114,7 @@ def restricted_plugin_kwargs():
from pretix.base.plugins import get_all_plugins
plugins_available = [
(p.module, p.name) for p in get_all_plugins(None)
(p.module, p.name) for p in get_all_plugins()
if (
not p.name.startswith('.') and
getattr(p, 'restricted', False) and
@@ -2695,6 +2695,20 @@ You can change your order details and view the status of your order at
{url}
Best regards,
Your {event} team""")) # noqa: W291
},
'mail_subject_order_invoice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Invoice {invoice_number}")),
},
'mail_text_order_invoice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
please find attached a new invoice for order {code} for {event}. This order has been placed by {order_email}.
Best regards,
Your {event} team""")) # noqa: W291
},
'mail_days_download_reminder': {

View File

@@ -524,8 +524,11 @@ class InvoiceAddressShredder(BaseDataShredder):
d = le.parsed_data
if 'invoice_data' in d and not isinstance(d['invoice_data'], bool):
for field in d['invoice_data']:
if d['invoice_data'][field]:
d['invoice_data'][field] = ''
if d['invoice_data'][field] and field != "transmission_type":
if field == "transmission_info":
d['invoice_data'][field] = {"_shredded": True}
else:
d['invoice_data'][field] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
@@ -600,6 +603,7 @@ class InvoiceShredder(BaseDataShredder):
i.additional_text = ""
i.invoice_to = ""
i.payment_provider_text = ""
i.transmission_info = {"_shredded": True}
i.save()
i.lines.update(description="")

View File

@@ -33,16 +33,23 @@
# License for the specific language governing permissions and limitations under the License.
import warnings
from typing import Any, Callable, List, Tuple
from typing import Any, Callable, Generic, List, Tuple, TypeVar
import django.dispatch
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.dispatch.dispatcher import NO_RECEIVERS
from .models import Event
from .models.event import Event
from .models.organizer import Organizer
from .plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
app_cache = {}
T = TypeVar('T')
def _populate_app_cache():
@@ -56,6 +63,9 @@ def get_defining_app(o):
if "sentry" in o.__module__:
o = o.__wrapped__
if hasattr(o, "__mocked_app"):
return o.__mocked_app
# Find the Django application this belongs to
searchpath = o.__module__
@@ -74,43 +84,71 @@ def get_defining_app(o):
return app
def is_app_active(sender, app):
def is_app_active(sender, app, allow_legacy_plugins=False):
if app == 'CORE':
return True
excluded = settings.PRETIX_PLUGINS_EXCLUDE
if sender and app and app.name in sender.get_plugins() and app.name not in excluded:
if not sender or not app or app.name in excluded:
return False
level = getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if isinstance(sender, Event):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Organizer) and allow_legacy_plugins:
# Deprecated behaviour: Event plugins that are registered on organizer level are considered active for
# all organizers in the context of signals that used to be global signals before the introduction of
# organizer-level plugin. A deprecation warning is emitted at .connect() time.
enabled = True
else:
raise ImproperlyConfigured(f"Cannot check if event-level plugin is active on {type(sender)}")
elif level == PLUGIN_LEVEL_ORGANIZER:
if isinstance(sender, Organizer):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Event):
enabled = app.name in sender.organizer.get_plugins()
else:
raise ImproperlyConfigured(f"Cannot check if organizer-level plugin is active on {type(sender)}")
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
if isinstance(sender, Organizer):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Event):
enabled = app.name in sender.get_plugins() and app.name in sender.organizer.get_plugins()
else:
raise ImproperlyConfigured(f"Cannot check if hybrid event/organizer-level plugin is active on {type(sender)}")
else:
raise ImproperlyConfigured("Unknown plugin level")
if enabled:
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
return True
return False
def is_receiver_active(sender, receiver):
def is_receiver_active(sender, receiver, allow_legacy_plugins=False):
if sender is None:
# Send to all events!
return True
app = get_defining_app(receiver)
return is_app_active(sender, app)
return is_app_active(sender, app, allow_legacy_plugins)
class EventPluginSignal(django.dispatch.Signal):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Event.
"""
class PluginSignal(Generic[T], django.dispatch.Signal):
type = None
def send(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
def _is_receiver_active(self, sender, receiver):
return is_receiver_active(sender, receiver)
def send(self, sender: T, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers that belong to
plugins enabled for the given Event.
sender is required to be an instance of ``pretix.base.models.Event``.
plugins enabled for the given event / organizer.
"""
if sender and not isinstance(sender, Event):
raise ValueError("Sender needs to be an event.")
if sender and not isinstance(sender, self.type):
raise ValueError(f"Sender needs to be of type {self.type}.")
responses = []
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
@@ -120,20 +158,18 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
response = receiver(signal=self, sender=sender, **named)
responses.append((receiver, response))
return responses
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
def send_chained(self, sender: T, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers. The return value of the first receiver
will be used as the keyword argument specified by ``chain_kwarg_name`` in the input to the
second receiver and so on. The return value of the last receiver is returned by this method.
sender is required to be an instance of ``pretix.base.models.Event``.
"""
if sender and not isinstance(sender, Event):
if sender and not isinstance(sender, self.type):
raise ValueError("Sender needs to be an event.")
response = named.get(chain_kwarg_name)
@@ -144,20 +180,18 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
named[chain_kwarg_name] = response
response = receiver(signal=self, sender=sender, **named)
return response
def send_robust(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
def send_robust(self, sender: T, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers. If a receiver raises an exception
instead of returning a value, the exception is included as the result instead of
stopping the response chain at the offending receiver.
sender is required to be an instance of ``pretix.base.models.Event``.
"""
if sender and not isinstance(sender, Event):
if sender and not isinstance(sender, self.type):
raise ValueError("Sender needs to be an event.")
responses = []
@@ -171,7 +205,7 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
try:
response = receiver(signal=self, sender=sender, **named)
except Exception as err:
@@ -193,6 +227,67 @@ class EventPluginSignal(django.dispatch.Signal):
return sorted_list
class EventPluginSignal(PluginSignal[Event]):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Event.
"""
type = Event
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
app = get_defining_app(receiver)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an EventPluginSignal but is not a pretix plugin"
)
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT)
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) not in allowed_levels:
# This check is redundant for now, but will be useful if we ever add other levels
raise ImproperlyConfigured(
f"{app} uses an EventPluginSignal but is not a plugin that can be active on event or organizer level"
)
return super().connect(receiver, sender, weak, dispatch_uid)
class OrganizerPluginSignal(PluginSignal[Organizer]):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Organizer.
"""
type = Organizer
def __init__(self, allow_legacy_plugins=False):
self.allow_legacy_plugins = allow_legacy_plugins
super().__init__()
def _is_receiver_active(self, sender, receiver):
return is_receiver_active(sender, receiver, allow_legacy_plugins=self.allow_legacy_plugins)
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
app = get_defining_app(receiver)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an OrganizerPluginSignal but is not a pretix plugin"
)
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) not in allowed_levels:
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) == PLUGIN_LEVEL_EVENT and self.allow_legacy_plugins:
warnings.warn(
'This signal will soon be only available for plugins that declare to be organizer-level',
stacklevel=3,
category=DeprecationWarning,
)
else:
raise ImproperlyConfigured(
f"{app} uses an OrganizerPluginSignal but is not a plugin that can be active on organizer level"
)
return super().connect(receiver, sender, weak, dispatch_uid)
class GlobalSignal(django.dispatch.Signal):
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
"""
@@ -211,10 +306,14 @@ class GlobalSignal(django.dispatch.Signal):
return response
class DeprecatedSignal(django.dispatch.Signal):
class DeprecatedSignal(GlobalSignal):
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
warnings.warn('This signal is deprecated and will soon be removed', stacklevel=3)
warnings.warn(
'This signal is deprecated and will soon be removed',
stacklevel=3,
category=DeprecationWarning,
)
super().connect(receiver, sender=None, weak=True, dispatch_uid=None)
@@ -257,8 +356,14 @@ class Registry:
When a new entry is registered, all accessor functions are called with the new entry as parameter.
Their return value is stored as the metadata value for that key.
"""
self.registered_entries = dict()
self.keys = keys
self.clear()
def clear(self):
"""
Removes all entries from the registry.
"""
self.registered_entries = dict()
self.by_key = {key: {} for key in self.keys.keys()}
def register(self, *objs):
@@ -318,20 +423,59 @@ class Registry:
)
class EventPluginRegistry(Registry):
class PluginAwareRegistry(Registry):
"""
A Registry which automatically annotates entries with a "plugin" key, specifying which plugin
the entry is defined in. This allows the consumer of entries to determine whether an entry is
enabled for a given event, or filter only for entries defined by enabled plugins.
enabled for a given event or organizer, or filter only for entries defined by enabled plugins.
.. code-block:: python
logtype, meta = my_registry.find(action_type="foo.bar.baz")
# meta["plugin"] contains the django app name of the defining plugin
"""
allowed_levels = [
PLUGIN_LEVEL_EVENT,
PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
]
def __init__(self, keys):
super().__init__({"plugin": lambda o: get_defining_app(o), **keys})
def get_plugin(o):
app = get_defining_app(o)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an PluginAwareRegistry but is not a pretix plugin"
)
level = getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT)
if level not in self.allowed_levels:
raise ImproperlyConfigured(
f"{app} has level {level} but should have one of {self.allowed_levels} to use this registry"
)
return app
super().__init__({"plugin": get_plugin, **keys})
def filter(self, active_in=None, **kwargs):
result = super().filter(**kwargs)
if active_in is not None:
result = (
(entry, meta)
for entry, meta in result
if is_app_active(active_in, meta['plugin'])
)
return result
def get(self, active_in=None, **kwargs):
item, meta = super().get(**kwargs)
if meta and active_in is not None:
if not is_app_active(active_in, meta['plugin']):
return None, None
return item, meta
EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
event_live_issues = EventPluginSignal()
@@ -426,7 +570,7 @@ This signal is sent out when a notification is sent.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_sales_channel_types = django.dispatch.Signal()
register_sales_channel_types = GlobalSignal()
"""
This signal is sent out to get all known sales channels types. Receivers should return an
instance of a subclass of ``pretix.base.channels.SalesChannelType`` or a list of such
@@ -444,10 +588,8 @@ subclass of pretix.base.exporter.BaseExporter
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_multievent_data_exporters = django.dispatch.Signal()
register_multievent_data_exporters = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``event``
This signal is sent out to get all known data exporters, which support exporting data for
multiple events. Receivers should return a subclass of pretix.base.exporter.BaseExporter
@@ -645,6 +787,16 @@ For backwards compatibility reasons, this signal is only sent when a **successfu
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
checkin_annulled = EventPluginSignal()
"""
Arguments: ``checkin``
This signal is sent out every time a check-in is annulled (i.e. changed to unsuccessful after it
already was successful).
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
logentry_display = EventPluginSignal()
"""
Arguments: ``logentry``
@@ -709,7 +861,7 @@ The ``sender`` keyword argument will contain the event. The ``target`` will cont
copy to, the ``source`` keyword argument will contain the product to **copy from**.
"""
periodic_task = django.dispatch.Signal()
periodic_task = GlobalSignal()
"""
This is a regular django signal (no pretix event signal) that we send out every
time the periodic task cronjob runs. This interval is not sharply defined, it can
@@ -718,13 +870,13 @@ idempotent, i.e. it should not make a difference if this is sent out more often
than expected.
"""
register_global_settings = django.dispatch.Signal()
register_global_settings = GlobalSignal()
"""
All plugins that are installed may send fields for the global settings form, as
an OrderedDict of (setting name, form field).
"""
gift_card_transaction_display = django.dispatch.Signal()
gift_card_transaction_display = GlobalSignal() # todo: replace with OrganizerPluginSignal?
"""
Arguments: ``transaction``, ``customer_facing``
@@ -936,7 +1088,7 @@ return a dictionary mapping names of attributes in the settings store to DRF ser
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
customer_created = GlobalSignal()
customer_created = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``customer``
@@ -946,7 +1098,7 @@ object is given as the first argument.
The ``sender`` keyword argument will contain the organizer.
"""
customer_signed_in = GlobalSignal()
customer_signed_in = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``customer``
@@ -956,7 +1108,7 @@ is given as the first argument.
The ``sender`` keyword argument will contain the organizer.
"""
device_info_updated = django.dispatch.Signal()
device_info_updated = GlobalSignal() # todo: replace with OrganizerPluginSignal?
"""
Arguments: ``old_device``, ``new_device``

View File

@@ -60,11 +60,17 @@
</td>
<td>
{{ event.name }}
{% if not event.has_subevents and event.settings.show_dates_on_frontpage %}
<br>
{{ event.get_date_range_display }}
{% if event.settings.show_times %}
{{ event.date_from|date:"TIME_FORMAT" }}
{% if not event.has_subevents %}
{% if event.settings.show_dates_on_frontpage %}
<br>
{{ event.get_date_range_display }}
{% if event.settings.show_times %}
{{ event.date_from|date:"TIME_FORMAT" }}
{% endif %}
{% endif %}
{% if event.location %}
<br>
{{ event.location|oneline }}
{% endif %}
{% endif %}
</td>

View File

@@ -21,38 +21,107 @@
#
import pycountry
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import pgettext
from django_countries.fields import Country
from django_scopes import scope
from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
)
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import Organizer
from pretix.base.models.tax import VAT_ID_COUNTRIES
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
)
def states(request):
cc = request.GET.get("country", "DE")
def _info(cc):
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},
'street': {'required': 'if_any'},
'zipcode': {'required': 'if_any' if cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED else False},
'city': {'required': 'if_any' if cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED else False},
'state': {
'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS,
'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS,
'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False,
'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')),
},
'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False},
}
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return JsonResponse({'data': [], **info, })
return {'data': [], **info}
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return JsonResponse({
return {
'data': [
{'name': s.name, 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name)
],
**info,
})
}
def address_form(request):
cc = request.GET.get("country", "DE")
info = _info(cc)
if request.GET.get("invoice") == "true":
# Do not consider live=True, as this does not expose sensitive information and we also want it accessible
# from e.g. the backend when the event is not yet life.
organizer = get_object_or_404(Organizer, slug=request.GET.get("organizer"))
with (scope(organizer=organizer)):
event = get_object_or_404(organizer.events, slug=request.GET.get("event"))
country = Country(cc)
is_business = request.GET.get("is_business") == "business"
selected_transmission_type = request.GET.get("transmission_type")
transmission_type_required = request.GET.get("transmission_type_required") == "true"
info["transmission_types"] = []
for t in get_transmission_types():
if t.is_available(event=event, country=country, is_business=is_business):
result = {"name": str(t.public_name), "code": t.identifier}
if t.exclusive:
info["transmission_types"] = [result]
break
else:
info["transmission_types"].append(result)
info["transmission_type"] = {
# Hide transmission type if email is the only type since that's basically the backwards-compatible
# option
"visible": [t["code"] for t in info["transmission_types"]] != ["email"],
}
if selected_transmission_type not in [t["code"] for t in info["transmission_types"]]:
if transmission_type_required:
# The previously selected transmission type is no longer selectable, e.g. because
# of a country change. To avoid a second roundtrip to this endpoint, let's show
# the fields as if the first remaining option were selected (which is what the client
# side will now do).
selected_transmission_type = info["transmission_types"][0]["code"]
else:
selected_transmission_type = "-"
for transmission_type in get_transmission_types():
required = transmission_type.invoice_address_form_fields_required(
country=country,
is_business=is_business
)
visible = transmission_type.invoice_address_form_fields_visible(
country=country,
is_business=is_business
)
if transmission_type.identifier == selected_transmission_type:
for k, v in info.items():
if k in required:
v["required"] = True
if k in visible:
v["visible"] = True
for k, f in transmission_type.invoice_address_form_fields.items():
info[k] = {
"visible": transmission_type.identifier == selected_transmission_type and k in visible,
"required": transmission_type.identifier == selected_transmission_type and k in required
}
return JsonResponse(info)

View File

@@ -42,3 +42,4 @@ class PretixControlConfig(AppConfig):
def ready(self):
from .views import dashboards # noqa
from . import logdisplay # noqa
from .views import datasync # noqa

View File

@@ -1199,6 +1199,20 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
required=False,
widget=I18nMarkdownTextarea,
)
mail_subject_order_invoice = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
help_text=_("This will only be used if the invoice is sent to a different email address or at a different time "
"than the order confirmation."),
)
mail_text_order_invoice = I18nFormField(
label=_("Text"),
required=False,
widget=I18nMarkdownTextarea,
help_text=_("This will only be used if the invoice is sent to a different email address or at a different time "
"than the order confirmation."),
)
mail_subject_download_reminder = I18nFormField(
label=_("Subject sent to order contact address"),
required=False,
@@ -1350,6 +1364,8 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
'mail_text_order_payment_failed': ['event', 'order'],
'mail_subject_order_payment_failed': ['event', 'order'],
'mail_text_order_custom_mail': ['event', 'order'],
'mail_text_order_invoice': ['event', 'order', 'invoice'],
'mail_subject_order_invoice': ['event', 'order', 'invoice'],
'mail_text_download_reminder': ['event', 'order'],
'mail_subject_download_reminder': ['event', 'order'],
'mail_text_download_reminder_attendee': ['event', 'order', 'position'],

View File

@@ -107,11 +107,16 @@ def get_all_payment_providers():
return Event
with rolledback_transaction():
plugins = ",".join([app.name for app in apps.get_app_configs()])
organizer = Organizer.objects.create(
name="INTERNAL",
plugins=plugins,
)
event = Event.objects.create(
plugins=",".join([app.name for app in apps.get_app_configs()]),
plugins=plugins,
name="INTERNAL",
date_from=now(),
organizer=Organizer.objects.create(name="INTERNAL")
organizer=organizer,
)
event = FakeEvent(event)
provs = register_payment_providers.send(
@@ -220,6 +225,7 @@ class OrderFilterForm(FilterForm):
(_('Cancellations'), (
(Order.STATUS_CANCELED, _('Canceled (fully)')),
('cp', _('Canceled (fully or with paid fee)')),
('cany', _('Canceled (at least one position)')),
('rc', _('Cancellation requested')),
('cni', _('Fully canceled but invoice not canceled')),
)),
@@ -396,6 +402,16 @@ class OrderFilterForm(FilterForm):
).filter(
Q(status=Order.STATUS_PAID, has_pc=False) | Q(status=Order.STATUS_CANCELED)
)
elif s == 'cany':
s = OrderPosition.all.filter(
order=OuterRef('pk'),
canceled=True,
)
qs = qs.annotate(
has_pc_c=Exists(s)
).filter(
Q(has_pc_c=True) | Q(status=Order.STATUS_CANCELED)
)
if fdata.get('ordering'):
qs = qs.order_by(*get_deterministic_ordering(Order, self.get_order_by()))
@@ -474,16 +490,31 @@ class EventOrderFilterForm(OrderFilterForm):
fdata = self.cleaned_data
qs = super().filter_qs(qs)
# This is a little magic, but there's no option that does not confuse people and let's hope this confuses less
# people.
only_match_noncanceled_products = fdata.get('status') in (
Order.STATUS_PAID,
Order.STATUS_PAID + 'v',
Order.STATUS_PENDING,
Order.STATUS_PENDING + Order.STATUS_PAID,
)
if only_match_noncanceled_products:
canceled_filter = Q(all_positions__canceled=False)
elif fdata.get('status') in ('cp', 'cany'):
canceled_filter = Q(all_positions__canceled=True) | Q(status=Order.STATUS_CANCELED)
else:
canceled_filter = Q()
item = fdata.get('item')
if item:
if '-' in item:
var = item.split('-')[1]
qs = qs.filter(all_positions__variation_id=var, all_positions__canceled=False).distinct()
qs = qs.filter(canceled_filter, all_positions__variation_id=var).distinct()
else:
qs = qs.filter(all_positions__item_id=fdata.get('item'), all_positions__canceled=False).distinct()
qs = qs.filter(canceled_filter, all_positions__item_id=fdata.get('item')).distinct()
if fdata.get('subevent'):
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False).distinct()
qs = qs.filter(canceled_filter, all_positions__subevent=fdata.get('subevent')).distinct()
if fdata.get('question') and fdata.get('answer') is not None:
q = fdata.get('question')

View File

@@ -0,0 +1,130 @@
#
# 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 itertools import groupby
from django import forms
from django.forms import formset_factory
from django.utils.translation import gettext_lazy as _
from pretix.base.models import Question
from pretix.base.models.datasync import (
MODE_APPEND_LIST, MODE_OVERWRITE, MODE_SET_IF_EMPTY, MODE_SET_IF_NEW,
)
class PropertyMappingForm(forms.Form):
pretix_field = forms.CharField()
external_field = forms.CharField()
value_map = forms.CharField(required=False)
overwrite = forms.ChoiceField(
choices=[
(MODE_OVERWRITE, _("Overwrite")),
(MODE_SET_IF_NEW, _("Fill if new")),
(MODE_SET_IF_EMPTY, _("Fill if empty")),
(MODE_APPEND_LIST, _("Add to list")),
]
)
def __init__(self, pretix_fields, external_fields_id, available_modes, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["pretix_field"] = forms.ChoiceField(
label=_("pretix field"),
choices=pretix_fields_choices(pretix_fields, kwargs.get("initial", {}).get("pretix_field")),
required=False,
)
if external_fields_id:
self.fields["external_field"] = forms.ChoiceField(
widget=forms.Select(
attrs={
"data-model-select2": "json_script",
"data-select2-src": "#" + external_fields_id,
},
),
)
self.fields["external_field"].choices = [
(self["external_field"].value(), self["external_field"].value()),
]
self.fields["overwrite"].choices = [
(key, label) for (key, label) in self.fields["overwrite"].choices if key in available_modes
]
class PropertyMappingFormSet(formset_factory(
PropertyMappingForm,
can_order=True,
can_delete=True,
extra=0,
)):
template_name = "pretixcontrol/datasync/property_mappings_formset.html"
def __init__(self, pretix_fields, external_fields, available_modes, prefix, *args, **kwargs):
super().__init__(
form_kwargs={
"pretix_fields": pretix_fields,
"external_fields_id": prefix + "external-fields" if external_fields else None,
"available_modes": available_modes,
},
prefix=prefix,
*args, **kwargs)
self.external_fields = external_fields
def get_context(self):
ctx = super().get_context()
ctx["external_fields"] = self.external_fields
ctx["external_fields_id"] = self.prefix + "external-fields"
return ctx
def to_property_mappings_list(self):
"""
Returns a property mapping configuration as a JSON-serializable list of dictionaries.
Each entry specifies how to transfer data from one pretix field to one field in the external system:
- `pretix_field`: Name of a pretix data source field as declared in `pretix.base.datasync.sourcefields.get_data_fields`.
- `external_field`: Name of the target field in the external system. Implementation-defined by the sync provider.
- `value_map`: Dictionary mapping pretix value to external value. Only used for enumeration-type fields.
- `overwrite`: Mode of operation if the object already exists in the target system.
- `MODE_OVERWRITE` (`"overwrite"`) to always overwrite existing value.
- `MODE_SET_IF_NEW` (`"if_new"`) to only set the value if object does not exist in target system yet.
- `MODE_SET_IF_EMPTY` (`"if_empty"`) to only set the value if object does not exist in target system,
or the field is currently empty in target system.
- `MODE_APPEND_LIST` (`"append"`) if the field is an array or a multi-select: add the value to the list.
"""
mappings = [f.cleaned_data for f in self.ordered_forms]
return mappings
QUESTION_TYPE_LABELS = dict(Question.TYPE_CHOICES)
def pretix_fields_choices(pretix_fields, initial_choice):
pretix_fields = sorted(pretix_fields, key=lambda f: f.category)
grouped_fields = groupby(pretix_fields, lambda f: f.category)
return [
(f"{cat}", [
(f.key, f.label + " [" + QUESTION_TYPE_LABELS[f.type] + "]")
for f in fields
if not f.deprecated or f.key == initial_choice
])
for ((idx, cat), fields) in grouped_fields
]

View File

@@ -43,7 +43,7 @@ from django.forms import formset_factory, inlineformset_factory
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.html import conditional_escape
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
@@ -70,9 +70,9 @@ from pretix.base.forms.widgets import (
SplitDateTimePickerWidget, format_placeholders_help_text,
)
from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SalesChannel, Team,
Customer, Device, Event, EventMetaProperty, Gate, GiftCard,
GiftCardAcceptance, Membership, MembershipType, OrderPosition, Organizer,
ReusableMedium, SalesChannel, Team,
)
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink
@@ -695,7 +695,9 @@ class WebHookForm(forms.ModelForm):
self.fields['events'].choices = [
(
a.action_type,
mark_safe('{} <code>{}</code>'.format(a.verbose_name, a.action_type))
format_html('{} <code>{}</code><br><span class="text-muted">{}</span>', a.verbose_name, a.action_type, a.help_text)
if a.help_text else
format_html('{} <code>{}</code>', a.verbose_name, a.action_type)
) for a in get_all_webhook_events().values()
]
if self.instance and self.instance.pk:
@@ -1204,3 +1206,19 @@ class SalesChannelForm(I18nModelForm):
)
return d
class OrganizerPluginEventsForm(forms.Form):
events = SafeEventMultipleChoiceField(
queryset=Event.objects.none(),
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
}),
label=_("Events with active plugin"),
required=False,
)
def __init__(self, *args, **kwargs):
events = kwargs.pop('events')
super().__init__(*args, **kwargs)
self.fields['events'].queryset = events

View File

@@ -303,7 +303,7 @@ class VoucherBulkForm(VoucherForm):
}),
required=False,
help_text=_('You can either supply a list of email addresses with one email address per line, or the contents '
'of a CSV file with a title column and one or more of the columns "email", "number", "name", '
'of a CSV file with a title row and one or more of the columns "email", "number", "name", '
'or "tag".')
)
Recipient = namedtuple('Recipient', 'email number name tag')

View File

@@ -43,9 +43,11 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.strings import LazyI18nString
from pretix.base.datasync.datasync import datasync_providers
from pretix.base.logentrytypes import (
DiscountLogEntryType, EventLogEntryType, ItemCategoryLogEntryType,
ItemLogEntryType, LogEntryType, OrderLogEntryType, QuestionLogEntryType,
@@ -319,6 +321,14 @@ class OrderChangedSplitFrom(OrderLogEntryType):
_('Denied scan of position #{posid} at {datetime} for list "{list}", type "{type}", error code "{errorcode}".'),
_('Denied scan of position #{posid} for list "{list}", type "{type}", error code "{errorcode}".'),
),
'pretix.event.checkin.annulled': (
_('Annulled scan of position #{posid} at {datetime} for list "{list}", type "{type}".'),
_('Annulled scan of position #{posid} for list "{list}", type "{type}".'),
),
'pretix.event.checkin.annulment.ignored': (
_('Ignored annulment of position #{posid} at {datetime} for list "{list}", type "{type}".'),
_('Ignored annulment of position #{posid} for list "{list}", type "{type}".'),
),
'pretix.control.views.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
})
@@ -421,6 +431,51 @@ class OrderPrintLogEntryType(OrderLogEntryType):
)
class OrderDataSyncLogEntryType(OrderLogEntryType):
def display(self, logentry, data):
try:
from pretix.base.datasync.datasync import datasync_providers
provider_class, meta = datasync_providers.get(identifier=data['provider'])
data['provider_display_name'] = provider_class.display_name
except (KeyError, AttributeError):
data['provider_display_name'] = data.get('provider')
return super().display(logentry, data)
@log_entry_types.new_from_dict({
"pretix.event.order.data_sync.success": _("Data successfully transferred to {provider_display_name}."),
})
class OrderDataSyncSuccessLogEntryType(OrderDataSyncLogEntryType):
def display(self, logentry, data):
links = []
if data.get('provider') and data.get('objects'):
prov, meta = datasync_providers.get(identifier=data['provider'])
if prov:
for objs in data['objects'].values():
links.append(", ".join(
prov.get_external_link_html(logentry.event, obj['external_link_href'], obj['external_link_display_name'])
for obj in objs
if obj and 'external_link_href' in obj and 'external_link_display_name' in obj
))
return mark_safe(escape(super().display(logentry, data)) + "".join("<p>" + link + "</p>" for link in links))
@log_entry_types.new_from_dict({
"pretix.event.order.data_sync.failed.config": _("Transferring data to {provider_display_name} failed due to invalid configuration:"),
"pretix.event.order.data_sync.failed.exceeded": _("Maximum number of retries exceeded while transferring data to {provider_display_name}:"),
"pretix.event.order.data_sync.failed.permanent": _("Error while transferring data to {provider_display_name}:"),
"pretix.event.order.data_sync.failed.internal": _("Internal error while transferring data to {provider_display_name}."),
"pretix.event.order.data_sync.failed.timeout": _("Internal error while transferring data to {provider_display_name}."),
})
class OrderDataSyncErrorLogEntryType(OrderDataSyncLogEntryType):
def display(self, logentry, data):
errmes = data["error"]
if not isinstance(errmes, list):
errmes = [errmes]
return mark_safe(escape(super().display(logentry, data)) + "".join("<p>" + escape(msg) + "</p>" for msg in errmes))
@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
@@ -469,6 +524,11 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'),
'pretix.event.order.invoice.sent': _('The invoice {full_invoice_no} has been sent.'),
'pretix.event.order.invoice.sending_failed': _('The transmission of invoice {full_invoice_no} has failed.'),
'pretix.event.order.invoice.testmode_ignored': _('Invoice {full_invoice_no} has not been transmitted because '
'the transmission provider does not support test mode invoices.'),
'pretix.event.order.invoice.retransmitted': _('The invoice {full_invoice_no} has been scheduled for retransmission.'),
'pretix.event.order.comment': _('The order\'s internal comment has been updated.'),
'pretix.event.order.custom_followup_at': _('The order\'s follow-up date has been updated.'),
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
@@ -481,6 +541,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.email.error': _('Sending of an email has failed.'),
'pretix.event.order.email.attachments.skipped': _('The email has been sent without attached tickets since they '
'would have been too large to be likely to arrive.'),
'pretix.event.order.email.invoice': _('An invoice email has been sent.'),
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
'pretix.event.order.position.email.custom_sent': _('A custom email has been sent to an attendee.'),
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
@@ -516,11 +577,11 @@ class CoreOrderLogEntryType(OrderLogEntryType):
@log_entry_types.new_from_dict({
'pretix.voucher.added': _('The voucher has been created.'),
'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'),
'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'),
'pretix.voucher.expired.waitinglist': _(
'The voucher has been set to expire because the recipient removed themselves from the waiting list.'),
'pretix.voucher.changed': _('The voucher has been changed.'),
'pretix.voucher.deleted': _('The voucher has been deleted.'),
'pretix.voucher.added.waitinglist': _('The voucher has been sent to {email} through the waiting list.'),
})
class CoreVoucherLogEntryType(VoucherLogEntryType):
pass
@@ -658,6 +719,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.customer.anonymized': _('The account has been disabled and anonymized.'),
'pretix.customer.password.resetrequested': _('A new password has been requested.'),
'pretix.customer.password.set': _('A new password has been set.'),
'pretix.customer.email.error': _('Sending of an email has failed.'),
'pretix.reusable_medium.created': _('The reusable medium has been created.'),
'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'),
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'),
@@ -691,6 +753,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.user.anonymized': _('This user has been anonymized.'),
'pretix.user.oauth.authorized': _('The application "{application_name}" has been authorized to access your '
'account.'),
'pretix.user.email.error': _('Sending of an email has failed.'),
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
'pretix.control.auth.user.forgot_password.denied.repeated': _('A repeated password reset has been denied, as '
@@ -728,6 +791,25 @@ class CoreLogEntryType(LogEntryType):
pass
@log_entry_types.new_from_dict({
'pretix.organizer.plugins.enabled': _('The plugin has been enabled.'),
'pretix.organizer.plugins.disabled': _('The plugin has been disabled.'),
})
class OrganizerPluginStateLogEntryType(LogEntryType):
object_link_wrapper = _('Plugin {val}')
def get_object_link_info(self, logentry) -> Optional[dict]:
if 'plugin' in logentry.parsed_data:
app = app_cache.get(logentry.parsed_data['plugin'])
if app and hasattr(app, 'PretixPluginMeta'):
return {
'href': reverse('control:organizer.settings.plugins', kwargs={
'organizer': logentry.event.organizer.slug,
}) + '#plugin_' + logentry.parsed_data['plugin'],
'val': app.PretixPluginMeta.name
}
@log_entry_types.new_from_dict({
'pretix.event.item_meta_property.added': _('A meta property has been added to this event.'),
'pretix.event.item_meta_property.deleted': _('A meta property has been removed from this event.'),

View File

@@ -451,6 +451,11 @@ def get_global_navigation(request):
'url': reverse('control:global.sysreport'),
'active': (url.url_name == 'global.sysreport'),
},
{
'label': _('Data sync problems'),
'url': reverse('control:global.datasync.failedjobs'),
'active': (url.url_name == 'global.datasync.failedjobs'),
},
]
})
@@ -490,6 +495,13 @@ def get_organizer_navigation(request):
}),
'active': url.url_name == 'organizer.edit',
},
{
'label': _('Plugins'),
'url': reverse('control:organizer.settings.plugins', kwargs={
'organizer': request.organizer.slug,
}),
'active': url.url_name == 'organizer.settings.plugins' or url.url_name == 'organizer.settings.plugin-events',
},
{
'label': _('Event metadata'),
'url': reverse('control:organizer.properties', kwargs={
@@ -655,6 +667,18 @@ def get_organizer_navigation(request):
'icon': 'download',
})
if 'can_change_organizer_settings' in request.orgapermset:
merge_in(nav, [{
'parent': reverse('control:organizer.export', kwargs={
'organizer': request.organizer.slug,
}),
'label': _('Data sync problems'),
'url': reverse('control:organizer.datasync.failedjobs', kwargs={
'organizer': request.organizer.slug,
}),
'active': (url.url_name == 'organizer.datasync.failedjobs'),
}])
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
[]),

View File

@@ -32,11 +32,11 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django.dispatch import Signal
from pretix.base.signals import (
DeprecatedSignal, EventPluginSignal, GlobalSignal, OrganizerPluginSignal,
)
from pretix.base.signals import DeprecatedSignal, EventPluginSignal
html_page_start = Signal()
html_page_start = GlobalSignal()
"""
This signal allows you to put code in the beginning of the main page for every
page in the backend. You are expected to return HTML.
@@ -80,7 +80,7 @@ in pretix.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
nav_topbar = Signal()
nav_topbar = GlobalSignal()
"""
Arguments: ``request``
@@ -99,7 +99,7 @@ This is no ``EventPluginSignal``, so you do not get the event in the ``sender``
and you may get the signal regardless of whether your plugin is active.
"""
nav_global = Signal()
nav_global = GlobalSignal()
"""
Arguments: ``request``
@@ -150,7 +150,7 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
user_dashboard_widgets = Signal()
user_dashboard_widgets = GlobalSignal()
"""
Arguments: 'user'
@@ -221,7 +221,7 @@ Deprecated signal, no longer works. We just keep the definition so old plugins d
break the installation.
"""
nav_organizer = Signal()
nav_organizer = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: 'organizer', 'request'
@@ -350,14 +350,14 @@ will be passed a ``form`` variable with your form.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
oauth_application_registered = Signal()
oauth_application_registered = GlobalSignal()
"""
Arguments: ``user``, ``application``
This signal will be called whenever a user registers a new OAuth application.
"""
order_search_filter_q = Signal()
order_search_filter_q = GlobalSignal()
"""
Arguments: ``query``

View File

@@ -198,7 +198,7 @@
</li>
{% elif request.user.is_staff and staff_session %}
<li>
<a href="{% url 'control:user.sudo.stop' %}" class="danger">
<a href="{% url 'control:user.sudo.stop' %}" class="danger admin-only">
<i class="fa fa-id-card"></i> {% trans "End admin session" %}
</a>
</li>

View File

@@ -0,0 +1,74 @@
{% load i18n %}
{% load eventurl %}
{% load bootstrap3 %}
{% load escapejson %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Data transfer to external systems" %}
</h3>
</div>
<ul class="list-group">
{% for identifier, display_name, pending, objects in providers %}
<li class="list-group-item">
<form action="{% url "control:event.order.sync_job" organizer=event.organizer.slug event=event.slug code=order.code provider=identifier %}" method="post" class="form-inline pull-right">
{% csrf_token %}
{% if pending %}
{% if pending.not_before > now or pending.need_manual_retry %}
<button type="submit" name="run_job_now" value="{{ pending.pk }}" class="btn btn-default"><i class="fa fa-refresh"></i> {% trans "Retry now" %}</button>
{% endif %}
<button type="submit" name="cancel_job" value="{{ pending.pk }}" class="btn btn-danger"><i class="fa fa-times"></i> {% trans "Cancel" %}</button>
{% else %}
<button type="submit" class="btn btn-default"><i class="fa fa-refresh"></i> {% trans "Sync now" %}</button>
<input type="hidden" name="queue_sync" value="true">
{% endif %}
</form>
<p><b>{{ display_name }}</b></p>
{% if pending %}
<p>
{% if pending.need_manual_retry %}
<i class="fa fa-warning"></i>
{% trans "Error" %}: {{ pending.get_need_manual_retry_display }}
{% elif pending.failed_attempts %}
<i class="fa fa-warning"></i>
{% blocktrans trimmed with num=pending.failed_attempts max=pending.max_retry_attempts %}
Error. Retry {{ num }} of {{ max }}.
{% endblocktrans %}
{% if pending.not_before %}
{% blocktrans trimmed with datetime=pending.not_before|date:"SHORT_DATETIME_FORMAT" %}
Waiting until {{ datetime }}
{% endblocktrans %}
{% endif %}
{% elif pending.not_before > now %}
{% blocktrans trimmed with datetime=pending.not_before|date:"SHORT_DATETIME_FORMAT" %}
Waiting until {{ datetime }}
{% endblocktrans %}
{% else %}
<i class="fa fa-hourglass"></i> {% trans "Pending" %}
{% endif %}
<span class="text-muted">({% blocktrans trimmed with datetime=pending.triggered|date:"SHORT_DATETIME_FORMAT" %}triggered at {{ datetime }}
{% endblocktrans %})</span>
<!-- {{ pending.triggered_by }} / {{ pending.triggered }} -->
</p>
{% endif %}
<ul>
{% for obj in objects %}
<li>
{% if obj.external_link_html %}
{{ obj.external_link_html }}
{% else %}
{{ obj.external_object_type }}
{% trans "identified by" %} {{ obj.external_id_field }}
<em>{{ obj.id_value }}</em>
{% endif %}
&nbsp; <time class="text-muted" datetime="{{ obj.transmitted.isoformat }}">{{ obj.transmitted|date:"SHORT_DATETIME_FORMAT" }}</time>
</li>
{% empty %}
<li>{% trans "No data transmitted." %}</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</div>

View File

@@ -0,0 +1,86 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% block content %}
<h2>{% trans "Sync problems" %}</h2>
<p>
{% blocktrans trimmed %}
On this page, we provide a list of orders where data synchronization to an external system has failed.
You can start another attempt to sync them manually.
{% endblocktrans %}
</p>
<form method="post">
{% csrf_token %}
<table class="table table-hover">
<thead>
<tr>
<th>
{% if queue_items %}
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
{% endif %}
</th>
<th>{% trans "Order" %}</th>
<th>{% trans "Sync provider" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Failure mode" %}</th>
{% if staff_session %}
<th>in_flight</th><th>retry</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for item in queue_items %}
<tr>
<td><input type="checkbox" name="idlist" value="{{ item.pk }}"></td>
<td>
{% if staff_session %}{{ item.order.event.organizer.slug }} -{% endif %}
<a href="{% url "control:event.order" event=item.order.event.slug organizer=item.order.event.organizer.slug code=item.order.code %}">
{{ item.order.full_code }}
</a>
</td>
<td>{{ item.provider_display_name }}</td>
<td>
{{ item.triggered }}
{% if staff_session %}({{ item.triggered_by }}){% endif %}
</td>
<td>
{% if item.need_manual_retry %}
{{ item.get_need_manual_retry_display }}
{% else %}
{% blocktrans trimmed with datetime=item.not_before|date:"SHORT_DATETIME_FORMAT" %}
Temporary error, will retry after {{ datetime }}
{% endblocktrans %}
{% endif %}
{% if staff_session %}({{ item.need_manual_retry }}){% endif %}
</td>
{% if staff_session %}
<td>{{ item.in_flight }} ({{ item.in_flight_since }})</td><td>{{ item.failed_attempts }} / {{ item.max_retry_attempts }} ({{ item.not_before }})</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center">{% trans "No problems." %}</td>
{% if staff_session %}
<td></td><td></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
{% if queue_items %}
<tfoot>
<tr>
<td colspan="5">
<button type="submit" name="action" value="retry" class="btn btn-primary"><i class="fa fa-refresh"></i> {% trans "Retry selected" %}</button>
<button type="submit" name="action" value="cancel" class="btn btn-danger"><i class="fa fa-times"></i> {% trans "Cancel selected" %}</button>
</td>
{% if staff_session %}
<td></td><td></td>
{% endif %}
</tr>
</tfoot>
{% endif %}
</table>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% load i18n %}
{% load bootstrap3 %}
{% load escapejson %}
{% load formset_tags %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for f in formset %}
{% bootstrap_form_errors f %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ f.id }}
{% bootstrap_field f.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field f.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-md-4">
{% bootstrap_field f.pretix_field layout='inline' form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field f.external_field layout='inline' form_group_class="" %}
</div>
<div class="col-md-2">
{% bootstrap_field f.overwrite layout='inline' form_group_class="" %}
</div>
{{ f.value_map.as_hidden }}
<div class="col-md-2 text-right flip">
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
<i class="fa fa-edit"></i></button>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-4">
{% bootstrap_field formset.empty_form.pretix_field layout='inline' form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field formset.empty_form.external_field layout='inline' form_group_class="" %}
</div>
<div class="col-md-2">
{% bootstrap_field formset.empty_form.overwrite layout='inline' form_group_class="" %}
</div>
{{ f.value_map.as_hidden }}
<div class="col-md-2 text-right flip">
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
<i class="fa fa-edit"></i></button>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add property" %}</button>
</p>
</div>
{% if external_fields %}
{{ external_fields|json_script:external_fields_id }}
{% endif %}

View File

@@ -79,6 +79,15 @@
class="btn btn-primary">{% trans "Show affected orders" %}</a>
</div>
{% endif %}
{% if has_sync_problems %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
Orders in this event could not be <strong>synced to an external system</strong> as configured.
{% endblocktrans %}
<a href="{% url "control:event.datasync.failedjobs" event=request.event.slug organizer=request.event.organizer.slug %}"
class="btn btn-primary">{% trans "Show sync problems" %}</a>
</div>
{% endif %}
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
{% if request.event.has_subevents %}

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load getitem %}
{% block inside %}
<h1>{% trans "Invoice settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
@@ -59,6 +60,99 @@
{% bootstrap_field form.invoice_renderer_highlight_order_code layout="control" %}
{% bootstrap_field form.invoice_eu_currencies layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Invoice transmission" %}</legend>
<p>
{% blocktrans trimmed %}
pretix can transmit invoices using different transmission methods. Different transmission methods
might be required depending on country and industry. By default, sending invoices as PDF files
via email is always available. Other types of transmission can be added by plugins.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
Whether a transmission method listed here is actually selectable for customers may depend on
the country of the customer or whether the customer is entering a business address.
{% endblocktrans %}
</p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>{% trans "Transmission method" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
{% for t in transmission_types %}
{% if transmission_providers|getitem:t.identifier %}
<tbody>
<tr>
<th scope="colgroup" class="text-muted">
{{ t.verbose_name }}
</th>
<th>
{% if ready|getitem:t.identifier %}
<span class="text-success">
<span class="fa fa-check fa-fw"></span>
{% trans "Available" %}
{% if t.exclusive %}
<span data-toggle="tooltip" title="{% trans "When this type is available for an invoice address, no other type can be selected." %}">
{% trans "(exclusive)" %}
</span>
{% endif %}
</span>
{% else %}
<span class="text-muted">
<span class="fa fa-ban fa-fw"></span>
{% trans "Unavailable" %}
</span>
{% endif %}
</th>
<th></th>
</tr>
</tbody>
<tbody>
{% for p, is_ready, settings_url in transmission_providers|getitem:t.identifier %}
<tr>
<td>
{{ p.verbose_name }}
</td>
<td>
{% if is_ready %}
<span class="text-success">
<span class="fa fa-check fa-fw"></span>
{% trans "Available" %}
</span>
{% else %}
<span class="text-muted">
<span class="fa fa-ban fa-fw"></span>
{% trans "Not configured" %}
</span>
{% endif %}
</td>
<td class="text-right">
{% if settings_url %}
<a href="{{ settings_url }}" class="btn btn-default">
<span class="fa fa-cog" aria-hidden="true"></span>
{% trans "Settings" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
{% endif %}
{% endfor %}
</table>
</div>
<p>
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
<a href="{{ plugin_settings_url }}" class="btn btn-default">
<i class="fa fa-plus"></i> {% trans "Enable additional invoice transmission plugins" %}
</a>
</p>
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-default btn-lg" name="preview" value="preview" formtarget="_blank">

View File

@@ -81,7 +81,7 @@
<div class="col-lg-6 col-sm-12 col-xs-12">
{{ log.display }}
{% if staff_session %}
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>

View File

@@ -42,7 +42,7 @@
<div class="col-lg-6 col-sm-12 col-xs-12">
{{ log.display }}
{% if staff_session %}
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>

View File

@@ -117,6 +117,9 @@
{% blocktrans asvar title_order_custom_mail %}Order custom mail{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
{% blocktrans asvar title_order_custom_mail %}Invoice{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_invoice" title=title_order_custom_mail items="mail_subject_order_invoice,mail_text_order_invoice" %}
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_subject_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_subject_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}

View File

@@ -87,6 +87,17 @@
<span class="text-muted">{% trans "Not available" %}</span>
</div>
{% elif is_active %}
{% if plugin.level == "organizer" %}
<p class="text-muted">
<span class="fa fa-group" aria-hidden="true"></span>
{% trans "This plugin can only be disabled for the entire organizer account." %}
</p>
{% elif plugin.level == "event_organizer" %}
<p class="text-muted">
<span class="fa fa-group" aria-hidden="true"></span>
{% trans "After disabling this plugin, some functionality may remain active in the organizer account." %}
</p>
{% endif %}
<div class="plugin-action flip">
{% if navigation_links %}
<div class="btn-group">
@@ -112,14 +123,42 @@
</ul>
</div>
{% endif %}
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
{% if plugin.level == "organizer" %}
<a href="{% url "control:organizer.settings.plugins" organizer=request.organizer.slug %}?q={{ plugin.module|urlencode }}"
class="btn btn-default" target="_blank">
<span class="fa fa-external-link" aria-hidden="true"></span>
{% trans "Open in organizer settings" %}
</a>
{% else %}
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
{% endif %}
</div>
{% else %}
<div class="plugin-action flip">
<button class="btn btn-primary{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="enable">{% trans "Enable" %}</button>
</div>
{% if plugin.level == "organizer" %}
<p class="text-muted">
<span class="fa fa-group" aria-hidden="true"></span>
{% trans "This plugin can only be enabled for the entire organizer account." %}
</p>
<div class="plugin-action flip">
<a href="{% url "control:organizer.settings.plugins" organizer=request.organizer.slug %}?q={{ plugin.module|urlencode }}"
class="btn btn-default" target="_blank">
<span class="fa fa-external-link" aria-hidden="true"></span>
{% trans "Open in organizer settings" %}
</a>
</div>
{% else %}
{% if plugin.level == "event_organizer" and not plugin.module in request.organizer.get_plugins %}
<p class="text-muted">
<span class="fa fa-group" aria-hidden="true"></span>
{% trans "Enabling this plugin will enable some of its functionality for the entire organizer account." %}
</p>
{% endif %}
<div class="plugin-action flip">
<button class="btn btn-primary{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="enable">{% trans "Enable" %}</button>
</div>
{% endif %}
{% endif %}
{% if plugin.featured %}
</div>

View File

@@ -31,7 +31,7 @@
{% trans "Every event needs to be created as part of an organizer account. Currently, you do not have access to any organizer accounts." %}
</div>
{% if staff_session %}
<a href="{% url "control:organizers.add" %}" class="btn btn-default">
<a href="{% url "control:organizers.add" %}" class="btn btn-default admin-only">
{% trans "Create a new organizer" %}
</a>
{% endif %}

View File

@@ -37,7 +37,7 @@
<p>
{{ log.display }}
{% if staff_session %}
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>

View File

@@ -27,7 +27,8 @@
{% endif %}</strong>
</h4>
</summary>
<div id="invoice">
<div id="invoice"
data-address-information-url="{% url "js_helpers.address_form" %}?invoice=true&organizer={{ request.event.organizer.slug|urlencode }}&event={{ request.event.slug|urlencode }}">
<div class="panel-body">
{% bootstrap_form invoice_form layout="horizontal" %}
</div>

View File

@@ -271,24 +271,70 @@
<a href="{% url "control:event.invoice.download" invoice=i.pk event=request.event.slug organizer=request.event.organizer.slug %}" target="_blank">
{% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %}
{{ i.number }}</a>
({{ i.date|date:"SHORT_DATE_FORMAT" }})
{% if i.sent_to_customer.year == 1970 %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
({{ i.date|date:"SHORT_DATE_FORMAT" }}, {{ i.transmission_type_instance.verbose_name }})
{% if i.transmission_status == "unknown" %}
{# Legacy invoice, before the introduction of transmission status #}
{% if i.transmission_date.year == 1970 %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_date %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
</span>
{% endif %}
{% elif i.transmission_status == "pending" %}
{% if i.transmission_type_instance.enforce_transmission %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice is scheduled to be transmitted" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-clock-o text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa fa-envelope fa-background text-muted" data-toggle="tooltip" title="{% trans "Invoice is not yet transmitted" %}"></span>
{% endif %}
{% elif i.transmission_status == "inflight" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice is currently in transmission" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
<span class="fa fa-send text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.sent_to_customer %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
{% elif i.transmission_status == "testmode_ignored" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice not transmitted in test mode" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-ban text-warning fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_status == "failed" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice transmission failed" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-exclamation-circle text-danger fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_status == "completed" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice has been transmitted" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
</span>
{% endif %}
{% if i.transmission_status != "inflight" %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.retransmitinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
{% csrf_token %}
<button class="btn btn-default btn-xs" data-toggle="tooltip"
title="{{ i.transmission_type_instance.verbose_name }}">
{% if i.transmission_status == "pending" %}
{% trans "Transmit" %}
{% else %}
{% trans "Retransmit" %}
{% endif %}
</button>
</form>
{% endif %}
{% if not i.canceled %}
{% if request.event.settings.invoice_regenerate_allowed %}
{% if i.regenerate_allowed %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.regeninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
{% csrf_token %}
@@ -320,12 +366,6 @@
<br/>
{% endif %}
{% endfor %}
{% if invoices_send_link %}
<br/>
<a class="btn btn-default btn-xs" href="{{ invoices_send_link }}">
{% trans "Email invoices" %}
</a>
{% endif %}
{% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<br/>
<form class="form-inline helper-display-inline" method="post"
@@ -800,7 +840,7 @@
{{ p.html_info|safe }}
{% if staff_session %}
<p>
<a href="" class="btn btn-default btn-xs" data-expandpayment data-id="{{ p.pk }}">
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>
@@ -812,7 +852,7 @@
<tr>
<td colspan="1"></td>
<td colspan="5">
<a href="" class="btn btn-default btn-xs" data-expandpayment data-id="{{ p.pk }}">
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>
@@ -916,7 +956,7 @@
{% endif %}
{% if staff_session %}
<p>
<a href="" class="btn btn-default btn-xs" data-expandrefund
<a href="" class="btn btn-default btn-xs admin-only" data-expandrefund
data-id="{{ r.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
@@ -989,8 +1029,14 @@
<dt>{{ request.event.settings.invoice_address_custom_field }}</dt>
<dd>{{ order.invoice_address.custom_field }}</dd>
{% endif %}
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% if order.invoice_address.internal_reference %}
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% endif %}
{% for k, v in order.invoice_address.describe_transmission %}
<dt>{{ k }}</dt>
<dd>{{ v }}</dd>
{% endfor %}
</dl>
</div>
</div>

View File

@@ -74,7 +74,7 @@
<thead>
<tr>
<th>{% trans "Product" %}</th>
<th>{% trans "Canceled" %}¹</th>
<th>{% trans "Canceled" %}</th>
<th>{% trans "Expired" %}</th>
<th>{% trans "Approval pending" %}</th>
<th colspan="3" class="text-center">{% trans "Purchased" %}</th>
@@ -106,27 +106,27 @@
<tr class="item {% if tup.0 %}categorized{% endif %}">
<td>{{ item }}</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=c&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}&amp;status=cany&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ item.num.canceled|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=e&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}&amp;status=e&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ item.num.expired|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=pa&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}&amp;status=pa&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ item.num.unapproved|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=na&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}&amp;status=na&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ item.num.pending|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}&amp;status=p&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}&amp;status=p&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ item.num.paid|togglesum:request.event.currency }}
</a>
</td>
@@ -139,27 +139,27 @@
<tr class="variation {% if tup.0 %}categorized{% endif %}">
<td>{{ var }}</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=c&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=cany&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ var.num.canceled|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=e&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=e&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ var.num.expired|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=pa&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=pa&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ var.num.unapproved|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=na&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=na&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ var.num.pending|togglesum:request.event.currency }}
</a>
</td>
<td>
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=p&amp;provider={{ item.provider }}">
<a href="{{ listurl }}?item={{ item.id }}-{{ var.id }}&amp;status=p&amp;provider={{ item.provider }}{% if subevent %}&amp;subevent={{ subevent.pk }}{% endif %}">
{{ var.num.paid|togglesum:request.event.currency }}
</a>
</td>
@@ -185,7 +185,4 @@
</tfoot>
</table>
</div>
<p class="help-block">
¹ {% trans "If you click links in this column, you will only find orders that are canceled completely, while the numbers also include single canceled positions within valid orders." %}
</p>
{% endblock %}

View File

@@ -37,7 +37,7 @@
<div class="col-lg-6 col-sm-12 col-xs-12">
{{ log.display }}
{% if staff_session %}
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>

View File

@@ -12,7 +12,7 @@
{% blocktrans with name=organizer.name %}Organizer settings{% endblocktrans %}
{% if request.user.is_staff and staff_session %}
<a href="{% url "control:organizer.delete" organizer=organizer.slug %}"
class="btn btn-danger hidden-print">
class="btn btn-danger hidden-print admin-only">
<span class="fa fa-trash"></span>
</a>
{% endif %}

View File

@@ -89,7 +89,7 @@
</button>
{% endif %}
{% if staff_session and t.info %}
<pre><code>{{ t.info|pprint }}</code></pre>
<pre class="admin-only"><code>{{ t.info|pprint }}</code></pre>
{% endif %}
{% if t.acceptor and t.acceptor != request.organizer %}
<span class="text-muted">

View File

@@ -29,7 +29,7 @@
</div>
{% if staff_session %}
<p>
<a href="{% url "control:organizers.add" %}" class="btn btn-default">
<a href="{% url "control:organizers.add" %}" class="btn btn-default admin-only">
<span class="fa fa-plus"></span>
{% trans "Create a new organizer" %}
</a>

View File

@@ -67,7 +67,7 @@
<div class="col-lg-6 col-sm-12 col-xs-12">
{{ log.display }}
{% if staff_session %}
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
<a href="" class="btn btn-default btn-xs admin-only" data-expandlogs data-id="{{ log.pk }}">
<span class="fa-eye fa fa-fw"></span>
{% trans "Inspect" %}
</a>

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