Compare commits

...

301 Commits

Author SHA1 Message Date
Phin Wolkwitz 8164f469d3 Prefetch program times, add test for query count 2026-01-19 18:11:12 +01:00
Raphael Michel b5ad372bb2 Fix crash when not language is set 2026-01-16 21:39:38 +01:00
Raphael Michel cdea82d206 Peppol: Fix ID validator for Belgium (Z#23214624) 2026-01-16 21:38:55 +01:00
Raphael Michel de9045afcf Allow to combine language variant with region (fixes #3947, Z#23220951) (#5814)
* Allow to combine language variant with region (fixes #3947, Z#23220951)

This only affects babel-based formatting (currently: currencies and phone numbers),
**not** Django-based formatting (currently: date and time formats).

* Remove tests where I don'T actually know whats right

* Fix lookup order
2026-01-16 17:08:46 +01:00
Kian Cross 6b65cb4e33 Add daily and cumulative attendee graphs to the order statistics page (#5792)
The order statistics page previously only showed order-based graphs. This change
adds attendee-based daily and cumulative graphs.
2026-01-16 16:57:04 +01:00
Raphael Michel c4792800f0 Cart: Fix wrong rounding being displayed (#5816) 2026-01-16 16:00:50 +01:00
Raphael Michel ca23f7ebc2 License check: Recognize license keywords (fixes #5812) (#5815) 2026-01-16 15:33:03 +01:00
Raphael Michel 0259899e00 Discount: Respect addon grouping in line selection (Z#23220058) (#5782)
* Discount: Respect addon grouping in line selection (Z#23220058)

* Update src/pretix/base/models/discount.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-16 15:23:59 +01:00
Raphael Michel efb94265b2 Cart: Use price before rounding as custom price for plus button (#5780) 2026-01-16 15:13:45 +01:00
Kian Cross 2aa27f56f1 Exclude cancelled orders from paid orders graph (#5786)
The 'paid orders' time series on the statistics page currently counts orders
that were paid and later cancelled.

Filter the paid-by-day queryset to `Order.STATUS_PAID` with at least one
non-cancelled position, leaving the placed orders series unchanged, and update
the help text to clarify this behaviour.

Discussion: https://github.com/pretix/pretix/discussions/5774
2026-01-16 14:37:11 +01:00
Raphael Michel 4f3d90fc50 Bank transfer: Do not show reference before it is as complete as possible (fixes #5296) (#5621)
* Bank transfer: Do not show reference before it is as complete as possible (fixes #5296)

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

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Apply suggestion from @raphaelm

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-16 14:34:28 +01:00
Kian Cross 9cf66de437 Clarify fee inclusion in revenue-over-time graph help text (#5785)
Updates the help text for the revenue-over-time graph to clarify how fees are
treated. When viewing a subevent, revenue excludes all fees (including
cancellation fees). When viewing the full event, revenue includes all fees,
including cancellation fees from cancelled orders.
2026-01-16 14:27:14 +01:00
Kian Cross 9f4cbabd30 Include fee-cancelled positions in placed orders by product graph (#5791)
The 'placed orders by product' graph already includes orders that are pending,
expired, or fully cancelled without a fee. However, items cancelled with a fee
were omitted. This change ensures all placed orders are included in the graph,
including those cancelled with a fee.
2026-01-16 14:24:03 +01:00
Kian Cross 0fc2d6134f Add option to restrict anonymous access to order URLs (#4735)
* Add option to restrict anonymous access to order URLs

By default, users who place orders while logged in can still access
their order URLs without authentication. This raises potential
security risks, particularly if order confirmation emails are
forwarded.

This commit introduces an organiser-level setting to disable anonymous
access for such orders. When enabled, unauthenticated attempts to access
URLs starting with `/order/`, which are intended for the customer, are
redirected to the login page. Upon successful authentication, the user
is redirected back to the original order URL.

It is important to note that this change does not impact routes intended
for attendees (e.g., `/ticket/*`), which remain accessible without
authentication.

* Change name of setting for future clarity

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* Update message wording

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* Eliminate database query

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* Rename feature flag to fix breaking tests

* Refactor order access verification code into `OrderDetailsMixin`

* Add test for logged-in customer accessing another customer's order

* Refactor order access conditions to remove nesting

* Handle case where customer is not yet verified

* Add additional information to help message

* Fix multidomain issue

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* Merge order/position variants into single tests

* Add docstring explaining return type of `order` property

* Apply suggestion from @raphaelm

* Fix indentation

---------

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-01-16 13:46:08 +01:00
George Hickman 1e0e16642d Add more log entry types to the org-level logs page (#5787)
* Add more log entry types to the org-level logs page

all_logentries() limits the QuerySet to LogEntrys whose content object
is an Organizer.

This change expands that to get any LogEntry linked to the current
Organization.  It removes those that are linked directly to an Event,
since they are already served by the event-level logs page.

* Check active plugins with either Event or Organizer
2026-01-16 13:36:23 +01:00
Richard Schreiber a58403559e Translations: Update Swedish
Currently translated at 89.9% (5572 of 6193 strings)

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

powered by weblate
2026-01-16 10:24:59 +01:00
Lukas Bockstaller dfd53f0ea2 Waitinglist: lock entry to mitigate race-conditions when creating the voucher 2026-01-15 16:09:41 +01:00
Linnea Thelander 06250ef55e Translations: Update Swedish
Currently translated at 89.9% (5573 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Mario Montes ab3104fe65 Translations: Update Galician
Currently translated at 15.9% (985 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
CVZ-es bb6e424cde Translations: Update Spanish
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Mario Montes c2623dba60 Translations: Update Spanish
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
CVZ-es d8f7465b03 Translations: Update Spanish
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Mario Montes ac0546499b Translations: Update Spanish
Currently translated at 98.8% (251 of 254 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Mario Montes ebbb532478 Translations: Update Spanish
Currently translated at 99.9% (6192 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Hijiri Umemoto 94dad4d0d2 Translations: Update Chinese (Traditional Han script)
Currently translated at 92.3% (5721 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
chondaen12 a06cd687ba Translations: Update Thai
Currently translated at 0.6% (41 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
sandra r fd9f3ea6ed Translations: Update Galician
Currently translated at 15.8% (984 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Hijiri Umemoto 608622e3f3 Translations: Update Japanese
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Ruud Hendrickx 4d94294e5a Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 66.3% (4109 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
dependabot[bot] 4dbdadabb5 Update sphinx-rtd-theme requirement from ~=3.1.0rc2 to ~=3.1.0 (#5804) 2026-01-13 13:23:35 +01:00
Jiří Pastrňák d494c61cba Translations: Update Czech
Currently translated at 70.0% (4339 of 6193 strings)

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

powered by weblate
2026-01-12 09:44:26 +01:00
Jiří Pastrňák 55a7dfbff3 Translations: Update Czech
Currently translated at 70.0% (4339 of 6193 strings)

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

powered by weblate
2026-01-12 09:44:26 +01:00
Jiří Pastrňák b8c271cf9c Translations: Update Czech
Currently translated at 70.0% (4338 of 6193 strings)

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

powered by weblate
2026-01-12 09:44:26 +01:00
Raphael Michel 5af7e1b6d6 Silence useless log messages from celery in dev 2026-01-09 17:31:17 +01:00
luelista 9222ce0ecd datasync: Fix configuring value mappings on newly added property mappings (Z#23217990) (#5793) 2026-01-09 16:11:32 +01:00
dependabot[bot] 8afb0e43e0 Update sentry-sdk requirement from ==2.48.* to ==2.49.* (#5788)
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.48.0...2.49.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.49.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>
2026-01-09 13:36:20 +01:00
Raphael Michel c65fecf45e Fix #5765 -- Email rendering: Ampersands and placeholders in URLs (#5766) 2026-01-09 13:01:21 +01:00
George Hickman 1c684d62d4 Get the Organizer of organizer-level plugin log entries directly (#5784) 2026-01-08 14:41:34 +01:00
dependabot[bot] 48809dc477 Update dnspython requirement from ==2.7.* to ==2.8.* (#5770)
Updates the requirements on [dnspython](https://github.com/rthalley/dnspython) to permit the latest version.
- [Release notes](https://github.com/rthalley/dnspython/releases)
- [Changelog](https://github.com/rthalley/dnspython/blob/main/doc/whatsnew.rst)
- [Commits](https://github.com/rthalley/dnspython/compare/v2.7.0rc1...v2.8.0)

---
updated-dependencies:
- dependency-name: dnspython
  dependency-version: 2.8.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>
2026-01-08 13:13:20 +01:00
dependabot[bot] 71df116079 Bump django-bootstrap3 from 25.2 to 26.1 (#5764)
Bumps [django-bootstrap3](https://github.com/zostera/django-bootstrap3) from 25.2 to 26.1.
- [Changelog](https://github.com/zostera/django-bootstrap3/blob/main/CHANGELOG.md)
- [Commits](https://github.com/zostera/django-bootstrap3/compare/v25.2...v26.1)

---
updated-dependencies:
- dependency-name: django-bootstrap3
  dependency-version: '26.1'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 13:12:46 +01:00
dependabot[bot] ad64f6e88b Update pillow requirement from ==11.3.* to ==12.1.* (#5768)
Updates the requirements on [pillow](https://github.com/python-pillow/Pillow) to permit the latest version.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/11.3.0...12.1.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.1.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>
2026-01-08 12:42:49 +01:00
dependabot[bot] 891ba9d99c Update django-phonenumber-field requirement from ==8.3.* to ==8.4.* (#5771)
Updates the requirements on [django-phonenumber-field](https://github.com/stefanfoulis/django-phonenumber-field) to permit the latest version.
- [Release notes](https://github.com/stefanfoulis/django-phonenumber-field/releases)
- [Changelog](https://github.com/django-phonenumber-field/django-phonenumber-field/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/stefanfoulis/django-phonenumber-field/compare/8.3.0...8.4.0)

---
updated-dependencies:
- dependency-name: django-phonenumber-field
  dependency-version: 8.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>
2026-01-08 12:42:39 +01:00
dependabot[bot] 5cd1476a07 Update bleach requirement from ==6.2.* to ==6.3.* (#5767)
Updates the requirements on [bleach](https://github.com/mozilla/bleach) to permit the latest version.
- [Changelog](https://github.com/mozilla/bleach/blob/main/CHANGES)
- [Commits](https://github.com/mozilla/bleach/compare/v6.2.0...v6.3.0)

---
updated-dependencies:
- dependency-name: bleach
  dependency-version: 6.3.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>
2026-01-08 12:40:54 +01:00
dependabot[bot] cb393a0b31 Bump markdown from 3.9 to 3.10 (#5757)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.9 to 3.10.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.9.0...3.10.0)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: '3.10'
  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>
2026-01-08 12:39:45 +01:00
dependabot[bot] af59a89ecb Update pytest requirement from ==8.4.* to ==9.0.* (#5763)
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.4.0.dev0...9.0.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 12:39:21 +01:00
dependabot[bot] 1eb0008da9 Update isort requirement from ==6.1.* to ==7.0.* (#5760)
Updates the requirements on [isort](https://github.com/PyCQA/isort) to permit the latest version.
- [Release notes](https://github.com/PyCQA/isort/releases)
- [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PyCQA/isort/compare/6.1.0...7.0.0)

---
updated-dependencies:
- dependency-name: isort
  dependency-version: 7.0.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 12:39:14 +01:00
dependabot[bot] d6489c6dd8 Bump django-compressor from 4.5.1 to 4.6.0 (#5759)
Bumps [django-compressor](https://github.com/django-compressor/django-compressor) from 4.5.1 to 4.6.0.
- [Changelog](https://github.com/django-compressor/django-compressor/blob/develop/docs/changelog.txt)
- [Commits](https://github.com/django-compressor/django-compressor/compare/4.5.1...4.6)

---
updated-dependencies:
- dependency-name: django-compressor
  dependency-version: 4.6.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>
2026-01-08 12:38:44 +01:00
dependabot[bot] abe6acc9d8 Update redis requirement from ==7.0.* to ==7.1.* (#5758)
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/v7.0.0b1...v7.1.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 7.1.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>
2026-01-08 12:38:21 +01:00
dependabot[bot] 2dcbb791f0 Update sphinx-rtd-theme requirement from ~=3.1.0rc1 to ~=3.1.0rc2 (#5777)
Updates the requirements on [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) to permit the latest version.
- [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst)
- [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/3.1.0rc1...3.1.0rc2)

---
updated-dependencies:
- dependency-name: sphinx-rtd-theme
  dependency-version: 3.1.0rc2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 11:24:33 +01:00
dependabot[bot] 2efc40e20b Update django-otp requirement from ==1.6.* to ==1.7.* (#5779)
Updates the requirements on [django-otp](https://github.com/django-otp/django-otp) to permit the latest version.
- [Changelog](https://github.com/django-otp/django-otp/blob/master/CHANGES.rst)
- [Commits](https://github.com/django-otp/django-otp/compare/v1.6.0...v1.7.0)

---
updated-dependencies:
- dependency-name: django-otp
  dependency-version: 1.7.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>
2026-01-08 11:24:13 +01:00
Raphael Michel 0693681473 Drop support for Python 3.9 (#5783) 2026-01-08 11:22:58 +01:00
Jiří Pastrňák 3aabc8a163 Translations: Update Czech
Currently translated at 94.4% (240 of 254 strings)

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

powered by weblate
2026-01-08 11:19:59 +01:00
Jiří Pastrňák 062f8fa409 Translations: Update Czech
Currently translated at 69.9% (4333 of 6193 strings)

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

powered by weblate
2026-01-08 11:19:59 +01:00
CVZ-es 106339c928 Translations: Update Spanish
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-08 11:19:59 +01:00
CVZ-es 222ea08dd0 Translations: Update French
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-08 11:19:59 +01:00
Raphael Michel 62bc16f963 Translation status: Properly account for plurals 2026-01-07 09:20:37 +01:00
Raphael Michel 3332fc818a Update Peppol ID list
https://docs.peppol.eu/edelivery/codelists/changelog.html
2026-01-06 17:10:23 +01:00
Raphael Michel d87dbaf9e5 Docs: Update sphinx from 7.x to 9.x (#5755)
* Docs: Update sphinx from 7.x to 9.x

* Update docs.yml
2026-01-06 16:21:33 +01:00
CVZ-es 67580c4ca5 Translations: Update French
Currently translated at 99.8% (6182 of 6193 strings)

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

powered by weblate
2026-01-06 15:48:14 +01:00
et15 c5b32484b1 Translations: Update German
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-06 15:48:14 +01:00
Jiří Pastrňák b5560509ad Translations: Update Czech
Currently translated at 69.7% (4319 of 6193 strings)

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

powered by weblate
2026-01-06 15:48:14 +01:00
Luca Sorace "Stranck c78365ce43 API: Fix race conditions in OrderChangeSerializer (#5756)
* OrderPositionCreateForExistingOrderSerializer.create: Fix race condition

* OrderFeeCreateForExistingOrderSerializer.create: Fix race condition

* OrderChange API serializers: Fix import orders
2026-01-06 15:46:41 +01:00
Luca Sorace "Stranck 8cc12fa1c7 OrderChangeManager: add_position() returns a handle to the newly created position (#5557)
* OrderChangeManager: Add support for custom operations

* OrderChangeManager: Add callback to AddPosition operation

This is also meant as a way to fix #5548

* Refs #5557: Checkstyle fix

* Refs #5557: Added tests

* Refs #5557: Changes requested in the PR review

* Refs #5557: Fix error in previous merge conflict

* Refs #5557: PR review
2026-01-05 17:34:53 +01:00
dependabot[bot] 59c09e27fd Update django-phonenumber-field requirement from ==7.3.* to ==8.3.* (#5522)
* Update django-phonenumber-field requirement from ==7.3.* to ==8.3.*

Updates the requirements on [django-phonenumber-field](https://github.com/stefanfoulis/django-phonenumber-field) to permit the latest version.
- [Release notes](https://github.com/stefanfoulis/django-phonenumber-field/releases)
- [Changelog](https://github.com/stefanfoulis/django-phonenumber-field/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/stefanfoulis/django-phonenumber-field/compare/7.3.0...8.3.0)

---
updated-dependencies:
- dependency-name: django-phonenumber-field
  dependency-version: 8.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Remove invalid geo codes

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-01-05 17:31:39 +01:00
dependabot[bot] 4d68d24eca Update redis requirement from ==6.4.* to ==7.0.* (#5567)
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.4.0...v7.0.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 7.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>
2026-01-05 17:06:22 +01:00
dependabot[bot] cc5693017e Update django-countries requirement from ==7.6.* to ==8.2.* (#5660)
* Update django-countries requirement from ==7.6.* to ==8.2.*

Updates the requirements on [django-countries](https://github.com/SmileyChris/django-countries) to permit the latest version.
- [Changelog](https://github.com/SmileyChris/django-countries/blob/main/CHANGES.md)
- [Commits](https://github.com/SmileyChris/django-countries/compare/v7.6...v8.2.0)

---
updated-dependencies:
- dependency-name: django-countries
  dependency-version: 8.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update our helpers

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-01-05 16:55:05 +01:00
Raphael Michel 6a07b7d5d1 Translations: Fix translator comments 2026-01-05 16:16:43 +01:00
Jiří Pastrňák 26dc3486a0 Translations: Update Czech
Currently translated at 69.6% (4316 of 6193 strings)

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

powered by weblate
2026-01-05 15:53:33 +01:00
Jiří Pastrňák de60183456 Translations: Update Czech
Currently translated at 69.6% (4315 of 6193 strings)

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

powered by weblate
2026-01-05 15:53:33 +01:00
Raphael Michel 520bb9e378 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-05 15:53:33 +01:00
Raphael Michel 97e344e81a Translations: Update German
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-05 15:53:33 +01:00
Raphael Michel a3f5f33ed5 Translations: Update wordlist 2026-01-05 15:51:50 +01:00
pretix translation bot 5a123bf88f Translations: Update Japanese (#5752)
Currently translated at 100.0% (6172 of 6172 strings)

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

powered by weblate

Co-authored-by: Hijiri Umemoto <hijiri@umemoto.org>
Co-authored-by: Weblate <noreply@weblate.org>
2026-01-05 14:57:57 +01:00
Raphael Michel 64c52a5e36 Translations: Update word lists 2026-01-05 14:56:47 +01:00
Raphael Michel a60341afe9 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-01-05 13:13:11 +01:00
Raphael Michel 308e14bab3 Mail settings: Correctly declare plaintext email (Z#23218835) (#5738)
* Mail settings: Correctly declare plaintext email (Z#23218835)

* Apply suggestions from code review

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

* Update escaping

* Escaping update

---------

Co-authored-by: luelista <weller@rami.io>
2026-01-05 12:33:43 +01:00
Raphael Michel aa5f635932 Customer account: Actually show value of gift card 2026-01-05 12:31:14 +01:00
dependabot[bot] 66a9902eb4 Update pypdf requirement from ==6.4.* to ==6.5.* (#5745)
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/6.4.0...6.5.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.5.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>
2026-01-05 12:20:57 +01:00
Richard Schreiber 79a58fe104 Improve description for addons option count (Z#23219101) (#5746) 2026-01-05 12:17:29 +01:00
Raphael Michel bb5a9bdbf1 PDF rendering: Do not create TTFont if already cached (#5748)
This provides a massive speedup for invoice rendering
2026-01-05 12:15:35 +01:00
dependabot[bot] 449b960438 Update css-inline requirement from ==0.18.* to ==0.19.* (#5749) 2026-01-05 12:14:59 +01:00
Yasunobu YesNo Kawaguchi a3f247117c Translations: Update Japanese
Currently translated at 100.0% (6172 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Jiří Pastrňák e279ecb423 Translations: Update Czech
Currently translated at 69.8% (4313 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Yasunobu YesNo Kawaguchi ca6a650398 Translations: Update Japanese
Currently translated at 100.0% (6172 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Yasunobu YesNo Kawaguchi 696e5602ac Translations: Update Japanese
Currently translated at 100.0% (6172 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Hijiri Umemoto 4c7987cef6 Translations: Update Estonian
Currently translated at 1.1% (3 of 254 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Hijiri Umemoto 37c65030f8 Translations: Update Estonian
Currently translated at 0.1% (5 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Hijiri Umemoto 0d1673136f Translations: Update Chinese (Traditional Han script)
Currently translated at 92.6% (5717 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Hijiri Umemoto 32d8dce6aa Translations: Update Japanese
Currently translated at 99.9% (6170 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Ruud Hendrickx 8a2ecb4e97 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 66.1% (4081 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Ruud Hendrickx 91348e3b00 Translations: Update Dutch
Currently translated at 96.5% (5956 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Jan Van Haver 459f4f84c7 Translations: Update Dutch
Currently translated at 96.4% (5955 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Ruud Hendrickx 31a1385946 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 63.9% (3948 of 6172 strings)

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

powered by weblate
2026-01-05 12:10:04 +01:00
Raphael Michel adfd0bfcfd Event list: Fix presale start date (Z#23219798) 2026-01-05 11:33:09 +01:00
Martin Gross ef7433dbcd Docs/Fundamentials: Fix spelling 2025-12-23 15:38:14 +01:00
Raphael Michel ebbd18bb26 Category selection: Search internal names 2025-12-22 11:29:23 +01:00
Raphael Michel fc4ce102b6 Widget: Hide dialogs by default 2025-12-22 09:26:47 +01:00
Raphael Michel 8854ae3187 Sendmail: Chunk query to prevent high memory load (Z#23217167) (#5699) 2025-12-19 15:44:43 +01:00
Daniel Branda c5a91ef479 Translations: Update Italian
Currently translated at 39.8% (2458 of 6172 strings)

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

powered by weblate
2025-12-19 15:44:18 +01:00
Raphael Michel aa9c478c30 [SECURITY] Prevent access to arbitrary cached files by UUID (CVE-2025-14881) 2025-12-19 12:59:21 +01:00
Richard Schreiber 847dc0f992 Re-add missing trimmed for blocktrans (#5735) 2025-12-18 20:28:06 +01:00
Raphael Michel daaae85865 Fix failing test 2025-12-18 16:11:30 +01:00
Raphael Michel 06770bcef5 Translations: Update German
Currently translated at 100.0% (6172 of 6172 strings)

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

powered by weblate
2025-12-18 16:05:53 +01:00
Raphael Michel dc6eae4708 Translations: Update German
Currently translated at 100.0% (6172 of 6172 strings)

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

powered by weblate
2025-12-18 16:05:53 +01:00
Daniel Branda bf8bb78d2a Translations: Update Italian
Currently translated at 38.6% (2384 of 6172 strings)

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

powered by weblate
2025-12-18 16:05:53 +01:00
Renne Rocha 091be266fc Translations: Update Portuguese (Brazil)
Currently translated at 90.5% (5590 of 6172 strings)

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

powered by weblate
2025-12-18 16:05:53 +01:00
dependabot[bot] dde655f7d6 Update fakeredis requirement from ==2.32.* to ==2.33.* (#5730)
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.32.0...v2.33.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.33.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-18 16:04:39 +01:00
Raphael Michel 409e64d5f2 Fix off-by-one error in voucher validation during cart extension (#5716)
* Fix typo in calculation

* Do not double-call extend_expired_positions in tests, make it private
2025-12-18 14:47:56 +01:00
Richard Schreiber 5d67a4fa33 Fix seatingframe missing voucher (#5734) 2025-12-18 14:24:49 +01:00
Richard Schreiber 4eb2c50d95 Fix widget-css etag version limit (#5733)
* Fix widget-css etag version limit

* make etag none if version bigger than version_max
2025-12-18 14:24:18 +01:00
dependabot[bot] a7e85a157d Update sentry-sdk requirement from ==2.47.* to ==2.48.* (#5726)
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.47.0...2.48.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.48.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-12-17 17:23:43 +01:00
Alexander Schwartz 4c3584c788 Pick the failed order count from value parameter for the message (#5722)
Closes #5721
2025-12-17 17:23:11 +01:00
Raphael Michel e466c4fb72 Refactor validation of cart contents, fix purchase of inactive subevent (Z#23217806) (#5715)
* Refactor validation of cart contents, fix purchase of inactive subevent (Z#23217806)

* Apply suggestions from code review

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Review notes

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2025-12-17 16:59:26 +01:00
Raphael Michel d0d7670ca5 Data sync: Allow more flexibility on list separators (#5718) 2025-12-17 16:23:07 +01:00
Richard Schreiber a17a098b15 Exclude data-dir from code style checks (#5725) 2025-12-17 16:22:42 +01:00
sandra r 40516ab8e0 Translations: Update Galician
Currently translated at 15.9% (983 of 6172 strings)

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

powered by weblate
2025-12-17 16:21:25 +01:00
sandra r 3ca343fabc Translations: Update Galician
Currently translated at 15.9% (982 of 6172 strings)

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

powered by weblate
2025-12-17 16:21:25 +01:00
Lachlan Struthers 7304b7f24b Translations: Update Albanian
Currently translated at 91.3% (232 of 254 strings)

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

powered by weblate
2025-12-17 16:21:25 +01:00
Lachlan Struthers abaf968103 Translations: Update Albanian
Currently translated at 1.1% (71 of 6172 strings)

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

powered by weblate
2025-12-17 16:21:25 +01:00
Lachlan Struthers 86e2f5a155 Translations: Update Albanian
Currently translated at 69.6% (177 of 254 strings)

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

powered by weblate
2025-12-17 16:21:25 +01:00
Lachlan Struthers 4c64af02c1 Translations: Update Albanian
Currently translated at 0.8% (52 of 6172 strings)

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

powered by weblate
2025-12-17 16:21:25 +01:00
Phin Wolkwitz 11df4398e1 Fix presale date display in calendar (Z#23216645) (#5710)
Fix presale date display in calendar and introduce a template tag
2025-12-17 16:18:59 +01:00
Lukas Bockstaller 2e89fc0a94 Questions: filter answers by dateFrame (Z#23216406) (#5706)
* replace manual form with QuestionFilterForm

* move form to form/item.py

* filter using a dateFrameField

* rename QuestionFilterForm to QuestionAnswerFilterForm

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* pass existing `opqs` into `filter_qs`

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* clean up filters

* fix view errors

* add labels

* display validation failures on field/label

* fix linting issues

* adjust datetime comparisons from lte to lt & gte to gt

* Change filter-form layout similar to order-filter-form

* improve label texts

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* use order constants

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* use Order Constants in Form where possible

* Change phrasing from Subevent to Date

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* include product variations in products filter

* repair time zone comparisons

* fix linting

* move filter form to form/filter.py

* remove references to timezone.utc

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* remove manual class statements

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* removes unnecessary check

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* fix datetime comparison

* Add full stop to error message to match style

* unify var-names and code-indent

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-12-15 12:46:06 +01:00
Raphael Michel 510c4850a5 Merge branch 'Add-Promptpay-for-stripe' (#5670) 2025-12-12 09:08:12 +01:00
Raphael Michel b13368d614 Event creation: Do not declare tax rate as optional (fixes #4794) (#5619) 2025-12-12 08:59:07 +01:00
Ana Rute Pacheco Vivas b5cc8b368b Translations: Update Portuguese (Portugal)
Currently translated at 83.2% (5140 of 6172 strings)

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

powered by weblate
2025-12-12 08:59:04 +01:00
Renne Rocha 87c30d0acb Translations: Update Portuguese (Brazil)
Currently translated at 90.4% (5585 of 6172 strings)

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

powered by weblate
2025-12-12 08:59:04 +01:00
Raphael Michel ffed8b29b1 Bank transfer: Allow CAMT import (#5601) 2025-12-12 08:58:52 +01:00
Ana Rute Pacheco Vivas 53fbb64225 Translations: Update Portuguese (Portugal)
Currently translated at 50.3% (128 of 254 strings)

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

powered by weblate
2025-12-10 17:02:20 +01:00
Ana Rute Pacheco Vivas e10ec4074b Translations: Update Portuguese (Portugal)
Currently translated at 83.1% (5135 of 6172 strings)

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

powered by weblate
2025-12-10 17:02:20 +01:00
Lachlan Struthers 7f2dc77aca Translations: Update Albanian
Currently translated at 41.3% (105 of 254 strings)

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

powered by weblate
2025-12-10 17:02:20 +01:00
Lachlan Struthers 199a3bf1e7 Translations: Update Albanian
Currently translated at 0.6% (39 of 6172 strings)

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

powered by weblate
2025-12-10 17:02:20 +01:00
Raphael Michel 904aa807a3 Footer link form: Add placeholder (Z#23217115) 2025-12-10 16:49:09 +01:00
Praveen Kathirvasan 0e41353a0e Add "Pay by bank" option for UK customers via Stripe (#5648)
* Add support for 'Pay by bank (UK)' payment method via Stripe

* Add 'Pay by bank' payment provider to Stripe integration

* Enhance Stripe integration: Allow UK bank payments and update imports

* Remove UK-specific payment method options from StripePayByBank integration

* Remove some UK references

---------

Co-authored-by: Raphael Michel <michel@rami.io>
2025-12-09 13:25:52 +01:00
Raphael Michel 82ca50c7ff Fix templates 2025-12-09 12:42:47 +01:00
Daniel 3437b64947 Add PromptPay support (#5)
* Handle PromptPay QR flow

* Send billing email for PromptPay

* fix isort

* Update payment.py

* Update signals.py

---------

Co-authored-by: Chondaen <chondaen12@1000WA>
2025-12-09 12:28:28 +01:00
Raphael Michel b895d9bbca Import large package lazily to speed up startup (#5636)
* Import large package lazily to speed up startup

* Make all jsonschema imports lazy
2025-12-09 09:52:53 +01:00
Raphael Michel f214edaf34 Timeline: Fix incorrect string formatting (fixes #5614) (#5617) 2025-12-09 08:52:09 +01:00
Raphael Michel 165a47b593 Bank transfer: Auto-ignore all 0-valued transactions (fixes #5168) (#5620)
* Bank transfer: Auto-ignore all 0-valued transactions (fixes #5168)

* Fix failing test
2025-12-09 08:50:04 +01:00
Renne Rocha e06f281f1e Translations: Update Portuguese (Brazil)
Currently translated at 90.3% (5575 of 6172 strings)

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

powered by weblate
2025-12-09 08:49:57 +01:00
Renne Rocha 203c7e660d Translations: Update Portuguese (Brazil)
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2025-12-09 08:49:57 +01:00
Renne Rocha 8c360b8754 Translations: Update Portuguese (Brazil)
Currently translated at 90.2% (5572 of 6172 strings)

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

powered by weblate
2025-12-09 08:49:57 +01:00
Ruud Hendrickx 90b6511d11 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 64.0% (3951 of 6172 strings)

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

powered by weblate
2025-12-09 08:49:57 +01:00
Ruud Hendrickx bb356257cb Translations: Update Dutch
Currently translated at 96.3% (5945 of 6172 strings)

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

powered by weblate
2025-12-09 08:49:57 +01:00
sandra r e1950e408e Translations: Update Galician
Currently translated at 15.5% (958 of 6172 strings)

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

powered by weblate
2025-12-09 08:49:57 +01:00
Yasunobu YesNo Kawaguchi 99d5722ce1 Translations: Update Japanese
Currently translated at 99.9% (6166 of 6172 strings)

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

powered by weblate
2025-12-09 08:49:57 +01:00
luelista 324eeb8d40 Fix crash when imported CSV has invalid syntax (#5702) 2025-12-09 08:09:34 +01:00
Raphael Michel 449e8dc905 Event cancel form: Add missing rich=True flag 2025-12-08 09:58:54 +01:00
Raphael Michel c491c8232e Bank transfer: Allow dashes in event slug to be missing (Z#23216859) (#5682)
* Bank transfer: Allow dashes in event slug to be missing (Z#23216859)

* Update src/pretix/plugins/banktransfer/tasks.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Update src/pretix/plugins/banktransfer/tasks.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Apply suggestions from code review

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2025-12-05 10:54:03 +01:00
sandra r aa02cc7968 Translations: Update Galician
Currently translated at 15.5% (961 of 6172 strings)

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

powered by weblate
2025-12-05 10:36:32 +01:00
Renne Rocha cfa13d6b9d Translations: Update Portuguese (Brazil)
Currently translated at 90.2% (5572 of 6172 strings)

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

powered by weblate
2025-12-05 10:36:32 +01:00
Raphael Michel af4eabc800 URL generation: Fix bug if plugins declare both event_urls and organizer_urls (#5688)
* URL generation: Fix bug if plugins declare both event_urls and organizer_urls

* Add missing file

* Add license header
2025-12-05 10:22:28 +01:00
luelista e1f5678d7c Refactor payment QR code generation code and add SPAYD format (#5680)
Move generation of QR code contents out of the HTML template and into Python code, so it can
be reused in plugins and tested with unit tests. Add the SPAYD QR code format which is used in
Czech Republic and Slovakia [1]. Display BezahlCode QR codes only for German IBANs.

[1] https://en.wikipedia.org/wiki/Short_Payment_Descriptor
2025-12-04 14:15:29 +01:00
luelista 609b7c82ee Handle duplicate column names in CSV import (#5681)
- display a warning message to the user
- automatically rename columns by adding "__1", "__2", ... suffixes
2025-12-04 14:03:27 +01:00
Raphael Michel 8d66e1e732 Cart extension: Fix bundled product being removed from cart when sold out (#5690)
Instead, the entire bundle must be removed as it may not be sold
individually.
2025-12-04 11:48:40 +01:00
Richard Schreiber c925f094f2 Reduce item event queries in waitinglist assign 2025-12-04 11:01:30 +01:00
Richard Schreiber 5caaa8586d Fix accounting report pending payment timezone (#5698) 2025-12-04 10:59:57 +01:00
SJang1 1b1cf1557d Translations: Update Korean
Currently translated at 50.8% (3139 of 6172 strings)

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

powered by weblate
2025-12-04 10:40:16 +01:00
sandra r 35d8a7eec5 Translations: Update Galician
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2025-12-04 10:40:16 +01:00
sandra r d428c3e1a4 Translations: Update Galician
Currently translated at 14.0% (869 of 6172 strings)

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

powered by weblate
2025-12-04 10:40:16 +01:00
dependabot[bot] 63850f3139 Update sentry-sdk requirement from ==2.46.* to ==2.47.*
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.46.0...2.47.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-04 10:40:05 +01:00
Felix Rindt 04c8270d43 Update pricing.rst to fix number typo (#5691)
I think you meant to point out the difference to the values in the table above...
2025-12-04 07:27:36 +01:00
dependabot[bot] 74a960e239 Update celery requirement from ==5.5.* to ==5.6.* (#5676) 2025-12-03 17:00:53 +01:00
Raphael Michel 5a1bcae085 Invoice address: Improve VAT ID input (#5647)
* Remove unmaintained depdendency vat_moss

* VAT ID normalization: Auto-add country codes

* VAT ID: County-specific labels

* Invoice address: Allow to set VAT ID as required per country

* Fix failing tests

* Update src/pretix/base/settings.py

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

* Review fixes

---------

Co-authored-by: luelista <weller@rami.io>
2025-12-03 16:48:19 +01:00
SJang1 051eb78312 Translations: Update Korean
Currently translated at 50.8% (3140 of 6172 strings)

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

powered by weblate
2025-12-03 16:29:20 +01:00
Ana Rute Pacheco Vivas 15808e55fd Translations: Update Portuguese (Portugal)
Currently translated at 83.1% (5134 of 6172 strings)

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

powered by weblate
2025-12-03 16:29:20 +01:00
David Ibáñez Cerdeira c886c0b415 Translations: Update Galician
Currently translated at 9.2% (569 of 6172 strings)

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

powered by weblate
2025-12-03 16:29:20 +01:00
David Ibáñez Cerdeira 47472447eb Translations: Update Galician
Currently translated at 9.1% (567 of 6172 strings)

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

powered by weblate
2025-12-03 16:29:20 +01:00
Richard Schreiber 1a40215e91 Fix N+1 queries in API (#5684)
* Fix N+1 query in API quotas list

* fix membership N+1

* fix vouchers N+1 budget_used

* rename and reuse Voucher.annotate_budget_used_orders to budget_used

* fix flake8
2025-12-03 15:37:40 +01:00
Raphael Michel d3fde85c39 Fix typo in CSS variable 2025-12-02 17:47:45 +01:00
Richard Schreiber 40bd66cb86 Fix PayPal2 payment patch request (#5678) 2025-12-02 13:14:12 +01:00
Raphael Michel bdd94b1f8a Add prioritization to webhook/notifications queue (#5513)
* Add prioritization to webhook/notifications queue

* Add missing code

* Missing license header

* Fix argument

* Use redis pipeline

* Update license header
2025-12-02 09:13:01 +01:00
José Manuel Silva 1c907f6a6f Translations: Update Portuguese (Portugal)
Currently translated at 83.1% (5133 of 6172 strings)

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

powered by weblate
2025-12-01 13:49:40 +01:00
José Manuel Silva 39e3ed9c25 Translations: Update Portuguese (Portugal)
Currently translated at 83.2% (5136 of 6172 strings)

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

powered by weblate
2025-12-01 13:49:40 +01:00
Richard Schreiber 4b5711253e Fix display_add_to_cart for variations 2025-12-01 13:48:02 +01:00
Raphael Michel bd554c7c29 Update remaining icon files 2025-12-01 13:41:06 +01:00
Raphael Michel 2261951b15 Peppol: Live ID validation (#5602)
* Peppol: Live ID validation

* Always check both systems

* Simplify logic
2025-11-27 19:50:53 +01:00
Raphael Michel 0f82e1cae6 Update pretix logo to new version (#5651)
* Update pretix logo to new version

* Make favicon transparent

* Update src/pretix/static/pretixcontrol/scss/main.scss

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

* Update src/pretix/static/pretixcontrol/scss/main.scss

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

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-11-27 16:05:30 +01:00
dependabot[bot] b0760157ce Update sentry-sdk requirement from ==2.45.* to ==2.46.*
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.45.0...2.46.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-27 16:05:18 +01:00
dependabot[bot] de2dec9089 Update pypdf requirement from ==6.3.* to ==6.4.* (#5659)
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/6.3.0...6.4.0)

---
updated-dependencies:
- dependency-name: pypdf
  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-11-27 16:05:04 +01:00
Raphael Michel 446c8e622b Bump version to 2025.11.0.dev0 2025-11-27 15:34:32 +01:00
Raphael Michel 703be2ebb8 Bump version to 2025.10.0 2025-11-27 15:34:23 +01:00
Raphael Michel a56fbc896c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6172 of 6172 strings)

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

powered by weblate
2025-11-27 15:33:49 +01:00
Raphael Michel 7b6f5df985 Translations: Update German
Currently translated at 100.0% (6172 of 6172 strings)

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

powered by weblate
2025-11-27 15:33:49 +01:00
Raphael Michel d2087907d5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-11-27 14:58:19 +01:00
Mira cbc2e611a2 Translations: Update German (informal) (de_Informal)
Currently translated at 99.9% (6168 of 6173 strings)

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

powered by weblate
2025-11-27 14:57:18 +01:00
Mira 02126a48fe Translations: Update German
Currently translated at 99.9% (6168 of 6173 strings)

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

powered by weblate
2025-11-27 14:57:18 +01:00
Yasunobu YesNo Kawaguchi be9af94131 Translations: Update Japanese
Currently translated at 99.9% (6167 of 6173 strings)

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

powered by weblate
2025-11-27 14:57:18 +01:00
CVZ-es dbe1944996 Translations: Update Spanish
Currently translated at 100.0% (6173 of 6173 strings)

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

powered by weblate
2025-11-27 14:57:18 +01:00
CVZ-es 6181bdc2e9 Translations: Update French
Currently translated at 100.0% (6173 of 6173 strings)

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

powered by weblate
2025-11-27 14:57:18 +01:00
Ana Rute Pacheco Vivas fe40d1c491 Translations: Update Portuguese (Portugal)
Currently translated at 80.2% (4951 of 6173 strings)

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

powered by weblate
2025-11-27 14:57:18 +01:00
Raphael Michel 9f263fbe4f Hotfix linkified placeholders (#5663)
* Fix linkify placeholders

* Add URL test
2025-11-27 13:20:13 +01:00
Raphael Michel fdd34f387a [SECURITY] Prevent HTML injection through placeholders in emails
Co-authored-by: luelista <weller@pretix.eu>
2025-11-27 11:41:27 +01:00
Raphael Michel bfab523d83 Merge branch 'SECURITY-pw-change' into 'master'
[SECURITY] Fix old password not validated on password change

See merge request pretix/pretix!16
2025-11-26 19:39:32 +01:00
Raphael Michel 8f69cb166d [SECURITY] Fix old password not validated on password change 2025-11-26 19:39:32 +01:00
Martin Gross 2fc7c23960 Cart Fragment: Display description of OrderFee.FEE_TYPE_OTHER if description is set (as done in invoices) 2025-11-20 13:56:21 +01:00
Raphael Michel b0911c9e42 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-11-20 11:37:59 +01:00
Hijiri Umemoto a5aa1030e5 Translations: Update Chinese (Traditional Han script)
Currently translated at 91.9% (5675 of 6171 strings)

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

powered by weblate
2025-11-20 11:36:56 +01:00
Raphael Michel 681e682e73 Bank transfer: Consistancy in order of fields (fixes #5577) (#5625)
* Bank transfer: Consistancy in order of fields (fixes #5577)

* Delete unused template
2025-11-19 14:47:28 +01:00
Raphael Michel db7518735a Allow admins to inspect invoices (#5641)
This is helpful to debug invoice renderers or non-PDF invoices like
Peppol or other XML formats
2025-11-19 14:42:18 +01:00
Raphael Michel 9c80f3038a OIDC: Drop scopes validation (fixes #5464) (#5623)
* OIDC: Drop scopes validation (fixes #5464)

* Fix test

* Remove claims as well
2025-11-19 14:39:32 +01:00
Raphael Michel 4dc5bbae06 Invoices: Increase retry interval (#5640)
e.g. Invopop states that receipt confirmation in italy can take 24h
2025-11-19 12:30:37 +01:00
dependabot[bot] e997ca4242 Update sentry-sdk requirement from ==2.44.* to ==2.45.* (#5644)
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.44.0...2.45.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.45.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-11-19 08:05:56 +01:00
Hijiri Umemoto 278b4301e5 Translations: Update Chinese (Traditional Han script)
Currently translated at 95.2% (242 of 254 strings)

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

powered by weblate
2025-11-19 08:05:42 +01:00
Hijiri Umemoto b648f9c46c Translations: Update Chinese (Traditional Han script)
Currently translated at 91.9% (5676 of 6171 strings)

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

powered by weblate
2025-11-19 08:05:42 +01:00
Hijiri Umemoto 9ce16b60d2 Translations: Update Korean
Currently translated at 99.2% (252 of 254 strings)

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

powered by weblate
2025-11-19 08:05:42 +01:00
Hijiri Umemoto f4a7604632 Translations: Update Korean
Currently translated at 49.9% (3084 of 6171 strings)

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

powered by weblate
2025-11-19 08:05:42 +01:00
Hijiri Umemoto 7cebb3e93f Translations: Update Japanese
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2025-11-19 08:05:42 +01:00
Hijiri Umemoto c82726e13d Translations: Update Japanese
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-19 08:05:42 +01:00
Phin Wolkwitz 2fcfc336d0 Add field length validation for invoice settings (Z#23215182) (#5639)
Limit invoice settings field lengths, add min value for counter length
2025-11-18 15:51:34 +01:00
luelista 39ff84b2e2 Use unique column names in order position export for invoice vs. attendee company name (Z#23215261) (#5638) 2025-11-18 15:47:55 +01:00
Raphael Michel 44804f05f3 Event quickstart: Fix fields being marked as optional (fixes #3504) (#5627)
* Event quickstart: Fix fields being marked as optional (fixes #3504)

* Revert accidental changes

* Update src/pretix/static/pretixcontrol/js/ui/main.js

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

---------

Co-authored-by: luelista <weller@rami.io>
2025-11-18 15:46:11 +01:00
Richard Schreiber 5e828ab8af Fix tax-code keying function for tax-recalc (#5637) 2025-11-18 15:03:33 +01:00
Richard Schreiber 313f4f326b Fix program times having no item in clean (#5635)
This error occurs only when adding a program-time form in the frontend and not saving it, but removing it again and then saving the item.
2025-11-18 14:59:31 +01:00
dependabot[bot] ed43bf327e Update pypdf requirement from ==6.2.* to ==6.3.* (#5634)
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/6.2.0...6.3.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.3.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-11-18 14:56:30 +01:00
Theodore 30aabc6253 Stripe: Update Revolut Pay presentment currency list (#5631) 2025-11-18 14:56:01 +01:00
Raphael Michel 5eade62121 Bank transfer: Use less cryptic refund references (fixes #4289) (#5626)
* Bank transfer: Use less cryptic refund references (fixes #4289)

* Add condition back in

* Fix tests
2025-11-18 14:52:44 +01:00
Raphael Michel 2669afa1f8 Webhooks: Allow longer URLs (fixes #5443) (#5622) 2025-11-18 14:42:48 +01:00
Raphael Michel d42c6f9b72 Open Fix a missing log entry type (fixes #5570) 2025-11-18 14:42:29 +01:00
Yasunobu YesNo Kawaguchi 34f064ca33 Translations: Update Japanese
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-18 14:42:00 +01:00
CVZ-es ad8d0a270c Translations: Update Spanish
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-18 14:42:00 +01:00
CVZ-es 363fcc3b56 Translations: Update French
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-18 14:42:00 +01:00
CVZ-es 9521ec2c52 Translations: Update Spanish
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-18 14:42:00 +01:00
CVZ-es 688d341baf Translations: Update French
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-18 14:42:00 +01:00
Sanny cdd4001378 Translations: Update Italian
Currently translated at 36.6% (2264 of 6171 strings)

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

powered by weblate
2025-11-18 14:42:00 +01:00
Raphael Michel d8d56ff020 Disable switching currency when orders exist (fixes #2047) 2025-11-17 17:09:17 +01:00
Raphael Michel 44b3647689 Accounting report: Allow subclasses to skip tables (#5616) 2025-11-17 17:09:06 +01:00
Richard Schreiber 818bb76e89 Fix calendar before-date to check for events (#5608) 2025-11-17 16:39:20 +01:00
Raphael Michel 8c01cad06b Stripe: Use unified wording for redirect announcement (#5613) 2025-11-17 16:20:53 +01:00
Raphael Michel 86ca7c4440 Order page: Do not show download deadline if download is disabled (fixes #3144) (#5630) 2025-11-17 15:42:20 +01:00
Richard Schreiber d7b6856322 Fix not allowing program times on event series (API/copy) (#5595)
* Fix not allowing program times on event series (API/copy)

* Return 400 when reading endpoint in event series

* add docs program times not available on event series

* fix isort
2025-11-17 15:36:53 +01:00
Raphael Michel e2d9cbb41d Add regressiont est for #1832 2025-11-14 18:20:20 +01:00
Raphael Michel 57bc7563da Fix flake8 issue 2025-11-14 18:13:48 +01:00
Raphael Michel 7741e9f936 Remove misleading helptext (fixes #3555) 2025-11-14 17:45:55 +01:00
Sanny 2f08bb465a Translations: Update Italian
Currently translated at 36.5% (2257 of 6171 strings)

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

powered by weblate
2025-11-14 16:31:58 +01:00
CVZ-es 4fb048e3a9 Translations: Update Spanish
Currently translated at 99.9% (6170 of 6171 strings)

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

powered by weblate
2025-11-14 11:12:42 +01:00
CVZ-es 82af3012bd Translations: Update French
Currently translated at 99.9% (6170 of 6171 strings)

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

powered by weblate
2025-11-14 11:12:42 +01:00
Andrii Andriiashyn 11425f21e6 Translations: Update Ukrainian
Currently translated at 57.1% (3528 of 6171 strings)

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

powered by weblate
2025-11-14 11:12:42 +01:00
Andrii Andriiashyn 55f35a998b Translations: Update Ukrainian
Currently translated at 57.0% (3523 of 6171 strings)

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

powered by weblate
2025-11-14 11:12:42 +01:00
CVZ-es 53cfce2ce7 Translations: Update Spanish
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-14 11:12:42 +01:00
CVZ-es 68ce335034 Translations: Update French
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-14 11:12:42 +01:00
dependabot[bot] 6ce5c1a26a Update sentry-sdk requirement from ==2.43.* to ==2.44.* (#5606)
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.43.0...2.44.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.44.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-11-14 10:02:12 +01:00
dependabot[bot] ae4540acd7 Update pypdf requirement from ==6.1.* to ==6.2.* (#5604)
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/6.1.0...6.2.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.2.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-11-14 10:01:58 +01:00
luelista a814d31c9b Re-check maximum order size during _perform_order (Z#23213046) (#5586)
* Re-check maximum order size during _perform_order (Z#23213046)

* Add test case
2025-11-14 10:01:51 +01:00
Raphael Michel ef9863518b Fix syntax error 2025-11-14 09:57:29 +01:00
Raphael Michel eb740204d4 Invoice issuer address: Add state field (#5603)
* Invoice issuer address: Add state field

* Update src/pretix/base/settings.py

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

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

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

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-11-14 09:56:46 +01:00
Raphael Michel 5583298322 Auto-verify user email addresses on accepting invites (#5609)
* Auto-verify user email addresses on accepting invites

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

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

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-11-14 09:55:18 +01:00
Raphael Michel 74b06435a0 Meta properties: Add helper to sort values (Z#23213668) (#5597) 2025-11-14 09:49:40 +01:00
Raphael Michel a26b0c5512 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-12 17:21:35 +01:00
Raphael Michel 095e07b3f1 Translations: Update German
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-12 17:21:35 +01:00
Raphael Michel b2eb1b6231 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-12 17:21:35 +01:00
Raphael Michel 9d838f1d9c Translations: Update German
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-12 17:21:35 +01:00
Raphael Michel cbf6bd29b0 Translations: Update German (informal) (de_Informal)
Currently translated at 99.5% (6146 of 6171 strings)

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

powered by weblate
2025-11-12 17:21:35 +01:00
Raphael Michel 0e84df9af2 Translations: Update German
Currently translated at 100.0% (6171 of 6171 strings)

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

powered by weblate
2025-11-12 17:21:35 +01:00
Raphael Michel 7feacc8a1a Translations: Update German (informal) (de_Informal)
Currently translated at 99.3% (6132 of 6171 strings)

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

powered by weblate
2025-11-12 17:21:35 +01:00
Raphael Michel 5ada22dd15 Translations: Update German
Currently translated at 99.7% (6155 of 6171 strings)

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

powered by weblate
2025-11-12 17:21:35 +01:00
Raphael Michel 6d56011695 Translations: Update wordlists 2025-11-12 17:07:42 +01:00
Raphael Michel da167eacd5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-11-12 14:21:28 +01:00
Ana Rute Pacheco Vivas 5df0c55daa Translations: Update Portuguese (Portugal)
Currently translated at 50.0% (127 of 254 strings)

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

powered by weblate
2025-11-12 14:20:18 +01:00
Ana Rute Pacheco Vivas b01e798b48 Translations: Update Portuguese (Portugal)
Currently translated at 80.8% (4955 of 6132 strings)

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

powered by weblate
2025-11-12 14:20:18 +01:00
luelista 0256ee76db Optionally show organizer slug in select2 (#5605) 2025-11-12 13:28:31 +01:00
Raphael Michel e99eecb8be Product list: Show number of items currently in cart (Z#23212546) (#5599)
* Product list: Show number of items currently in cart

* Apply suggestions from code review

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

* Add display property

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-11-11 08:05:40 +01:00
CVZ-es d1ae579a6f Translations: Update Spanish
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-10 13:00:20 +01:00
Yasunobu YesNo Kawaguchi 90d3f50eba Translations: Update Japanese
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2025-11-10 13:00:20 +01:00
Yasunobu YesNo Kawaguchi c1b6d660a4 Translations: Update Japanese
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-10 13:00:20 +01:00
Linnea Thelander 0b88b63597 Translations: Update Swedish
Currently translated at 91.0% (5582 of 6132 strings)

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

powered by weblate
2025-11-10 13:00:20 +01:00
Richard Schreiber 0cc6439748 Fix API-docs missing item_program_times (#5594) 2025-11-07 13:04:04 +01:00
Richard Schreiber ad53c48d0f Fix price-column in item export for free variations 2025-11-07 11:57:06 +01:00
luelista 59a5c11ef6 Rename migration (#5592) 2025-11-07 11:40:27 +01:00
luelista 1cb2d443f9 Validation of user email addresses (#5434)
* Validation of user email addresses
* Improve email and password change forms
2025-11-07 11:17:34 +01:00
Raphael Michel a0dbf6c5db Force Django upgrade (CVE-2025-64459) 2025-11-06 15:04:03 +01:00
Phin Wolkwitz fd9d03786b Add program times for items (Z#23178639)
* Add program times for items

* Fix frontend date validation

* Add ical data for program times [wip]

* Improve ical data for program times

* Remove duplicate code and add comments

* Adjust migration

* Remove program times form for event series

* Add pdf placeholder [wip]

* Improve explanation text with suggestion

Co-authored-by: Raphael Michel <michel@pretix.eu>

* Fix import sorting

* Improve ical generation

* Improve ical entry description

* Fix migration

* Add copyability for program times fot items and events

* Update migration

* Add API endpoints/functions, fix isort

* Improve variable name

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

* Remove todo comment

* Add documentation, Change endpoint name

* Change related name

* Remove unnecessary code block

* Add program times to item API

* Fix imports

* Add log text

* Use daterange helper

* Add and update API tests

* Add another API test

* Add program times to cloning tests

* Update query count because of program times query

* Invalidate cached tickets on program time changes

* Reduce invalidation calls

* Update migration after rebase

* Apply improvements to invalidation from review

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

* remove unneccessary attr=item param

* remove unnecessary kwargs for formset_factory

* fix local var name being overwritten in for-loop

* fix empty formset being saved

* Use subevent if available

* make code less verbose

* remove double event-label in ical desc

* fix unnecessary var re-assign

* fix ev vs p.subevent

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-11-06 12:24:47 +01:00
Richard Schreiber 7041d40972 Invoice-PDF: split line.description into multiple rows so table can pagebreak (#5545) 2025-11-06 09:44:23 +01:00
Richard Schreiber 0b46982e6d Allow blocked seats to be booked in backend (#5585) 2025-11-06 08:02:42 +01:00
Yasunobu YesNo Kawaguchi 29906c6288 Translations: Update Japanese
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es 3380bd3e82 Translations: Update Spanish
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es 6ae8e7cbb6 Translations: Update French
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es 23c2d9266e Translations: Update Spanish
Currently translated at 99.9% (6130 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es ba155faaa3 Translations: Update French
Currently translated at 99.9% (6131 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
Núria Masclans fd177fa89f Translations: Update Catalan
Currently translated at 78.7% (200 of 254 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es 0b051c1400 Translations: Update Spanish
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es af8d0f0b65 Translations: Update French
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
Raphael Michel 1b7bba195c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
Raphael Michel f056f77dc0 Translations: Update German
Currently translated at 100.0% (6132 of 6132 strings)

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

powered by weblate
2025-11-04 12:04:23 +01:00
Richard Schreiber ee4e7f618f Sort answers according to questions order
* Sort answers according to questions (Z#23212280)

* Undo ordering in Meta-class

* filter and order answers only on invoice
2025-11-04 11:33:16 +01:00
dependabot[bot] cd450f1780 Bump @babel/core from 7.28.4 to 7.28.5 in /src/pretix/static/npm_dir (#5579)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.28.4 to 7.28.5.
- [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.5/packages/babel-core)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 11:26:29 +01:00
dependabot[bot] fc876978b2 Bump @rollup/plugin-babel in /src/pretix/static/npm_dir (#5581)
Bumps [@rollup/plugin-babel](https://github.com/rollup/plugins/tree/HEAD/packages/babel) from 6.0.4 to 6.1.0.
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/babel/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/url-v6.1.0/packages/babel)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-babel"
  dependency-version: 6.1.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-11-04 11:26:22 +01:00
dependabot[bot] d8efaa47f7 Update css-inline requirement from ==0.17.* to ==0.18.*
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.17.0...v0.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 11:25:45 +01:00
dependabot[bot] f0c3514588 Bump @babel/preset-env in /src/pretix/static/npm_dir (#5580)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.28.3 to 7.28.5.
- [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.5/packages/babel-preset-env)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 17:40:47 +01:00
dependabot[bot] e1ad4d9dba Bump @rollup/plugin-node-resolve in /src/pretix/static/npm_dir (#5578)
Bumps [@rollup/plugin-node-resolve](https://github.com/rollup/plugins/tree/HEAD/packages/node-resolve) from 16.0.1 to 16.0.3.
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/node-resolve/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/node-resolve-v16.0.3/packages/node-resolve)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-node-resolve"
  dependency-version: 16.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 17:40:15 +01:00
Raphael Michel 3ab587883e Translations: Add words to spellcheck wordlist 2025-11-03 17:40:04 +01:00
Raphael Michel b02e1a1515 Gift card payment: Clean-up some code (#5574)
* Remove apparently unused code

* Move templates that do not belong in pretixcontrol
2025-11-03 17:38:56 +01:00
Raphael Michel 41780add40 Gift cards: Remove nested form tags 2025-10-30 18:03:47 +01:00
Raphael Michel b07a61e4f1 Remove visible rounding mode 2025-10-30 17:54:06 +01:00
Raphael Michel dead2a9bed Bank transfer: Reorder fields for pending bank details (#5562)
* Reorder fields for pending bank transfer

* Remove unnecessary endif statement in pending.html

* Fix indentation in pending.html template
2025-10-30 16:40:39 +01:00
Raphael Michel 94389c3913 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-10-30 11:55:47 +01:00
Raphael Michel 3e972eddbf Allow to round taxes on order-level (#5019)
* Allow to round taxes on order-level

* Rename get_cart_total

* Persist rounding mode with order

* Add general docs

* Order creation API

* Update fee algorithm

* Rounding on payment method change

* Round when splitting order

* Fix failing tests

* Add settings page

* Add tests

* Replace algorithm

* Add test case for currency rounding

* Improve order change

* Update flowchart

* Update discount logic (more hypothetical, we don't store rounding on cart positions atm)

* Rename internal method

* Fix typo

* Update help text

* Apply suggestions from code review

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

* Order rounding refactor (#5571)

* Add RoundingCorrectionMixin providing before-rounding-values as properties

* Use gross_price_before_rounding in more places

* Update doc/development/algorithms/pricing.rst

Co-authored-by: Martin Gross <gross@rami.io>

* Allow to override on perform_order

* Rebase migration

* Fix event cancellation

---------

Co-authored-by: luelista <weller@rami.io>
Co-authored-by: Martin Gross <gross@rami.io>
2025-10-30 11:49:31 +01:00
dependabot[bot] cdeb1e86bd Update sentry-sdk requirement from ==2.42.* to ==2.43.*
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.42.0...2.43.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-30 11:33:10 +01:00
Raphael Michel 9a69b76880 API: Expose history of check-ins (Z#23206049) 2025-10-30 10:45:01 +01:00
Richard Schreiber 7d5df2b69e Fix required label for multi-checkbox form-groups (#5568) 2025-10-30 10:44:17 +01:00
Raphael Michel d203eee5ab Bump version to 2025.10.0.dev0 2025-10-30 09:53:25 +01:00
380 changed files with 209338 additions and 174397 deletions
+2 -2
View File
@@ -26,10 +26,10 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: 3.11
python-version: 3.13
- uses: actions/cache@v4
with:
path: ~/.cache/pip
+3 -3
View File
@@ -23,13 +23,13 @@ jobs:
name: Tests
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
python-version: ["3.10", "3.11", "3.13"]
database: [sqlite, postgres]
exclude:
- database: sqlite
python-version: "3.9"
- database: sqlite
python-version: "3.10"
- database: sqlite
python-version: "3.11"
services:
postgres:
image: postgres:15
+79 -95
View File
@@ -6,10 +6,14 @@
{%- else %}
{%- set titlesuffix = "" %}
{%- endif %}
{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %}
{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #}
{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%}
{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%}
<!DOCTYPE html>
<!--[if IE 8]><html class="no-js lt-ie9" lang="en" > <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en" > <!--<![endif]-->
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
<head>
<meta charset="utf-8">
{{ metatags }}
@@ -18,59 +22,50 @@
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
{% endblock %}
{#- CSS #}
{%- for css in css_files %}
{%- if css|attr("rel") %}
<link rel="{{ css.rel }}" href="{{ pathto(css.filename, 1) }}" type="text/css"{% if css.title is not none %} title="{{ css.title }}"{% endif %} />
{#- CSS #}
{%- for css_file in css_files %}
{%- if css_file|attr("filename") %}
{{ css_tag(css_file) }}
{%- else %}
<link rel="stylesheet" href="{{ pathto(css, 1) }}" type="text/css" />
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
{%- endif %}
{%- endfor %}
{%- endfor %}
{%- for cssfile in extra_css_files %}
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
{%- endfor -%}
{#- FAVICON #}
{%- if favicon_url %}
<link rel="shortcut icon" href="{{ favicon_url }}"/>
{%- endif %}
{#- FAVICON
favicon_url is the only context var necessary since Sphinx 4.
In Sphinx<4, we use favicon but need to prepend path info.
#}
{%- set _favicon_url = favicon_url | default(pathto('_static/' + (favicon or ""), 1)) %}
{%- if favicon_url or favicon %}
<link rel="shortcut icon" href="{{ _favicon_url }}"/>
{%- endif %}
{#- CANONICAL URL (deprecated) #}
{%- if theme_canonical_url and not pageurl %}
{#- CANONICAL URL (deprecated) #}
{%- if theme_canonical_url and not pageurl %}
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
{%- endif -%}
{%- endif -%}
{#- CANONICAL URL #}
{%- if pageurl %}
{#- CANONICAL URL #}
{%- if pageurl %}
<link rel="canonical" href="{{ pageurl|e }}" />
{%- endif -%}
{%- endif -%}
{#- JAVASCRIPTS #}
{%- block scripts %}
<!--[if lt IE 9]>
<script src="{{ pathto('_static/js/html5shiv.min.js', 1) }}"></script>
<![endif]-->
{%- if not embedded %}
{# XXX Sphinx 1.8.0 made this an external js-file, quick fix until we refactor the template to inherert more blocks directly from sphinx #}
{%- for scriptfile in script_files %}
{{ js_tag(scriptfile) }}
{%- endfor %}
{#- JAVASCRIPTS #}
{%- block scripts %}
{%- if not embedded %}
{%- for scriptfile in script_files %}
{{ js_tag(scriptfile) }}
{%- endfor %}
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
{%- if READTHEDOCS or DEBUG %}
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
{%- endif %}
{#- OPENSEARCH #}
{%- if use_opensearch %}
<link rel="search" type="application/opensearchdescription+xml"
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
{%- endif %}
{%- endif %}
{%- endblock %}
{%- endif %}
{%- endblock %}
{%- block linktags %}
{%- if hasdoc('about') %}
@@ -123,23 +118,23 @@
{% endblock %}
</div>
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
{% block menu %}
{#
The singlehtml builder doesn't handle this toctree call when the
toctree is empty. Skip building this for now.
#}
{% if 'singlehtml' not in builder %}
{% set global_toc = toctree(maxdepth=theme_navigation_depth|int, collapse=theme_collapse_navigation, includehidden=True) %}
{% endif %}
{% if global_toc %}
{{ global_toc }}
{% else %}
{%- block navigation %}
{#- Translators: This is an ARIA section label for the main navigation menu -#}
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="{{ _('Navigation menu') }}">
{%- block menu %}
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int,
collapse=theme_collapse_navigation|tobool,
includehidden=theme_includehidden|tobool,
titles_only=theme_titles_only|tobool) %}
{%- if toctree %}
{{ toctree }}
{%- else %}
<!-- Local TOC -->
<div class="local-toc">{{ toc }}</div>
{% endif %}
{% endblock %}
</div>
{%- endif %}
{%- endblock %}
</div>
{%- endblock %}
{% if theme_display_version %}
{%- set nav_version = version %}
@@ -158,53 +153,42 @@
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
{# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
<nav class="wy-nav-top" role="navigation" aria-label="top navigation">
{% block mobile_nav %}
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="{{ pathto('index') }}">{{ project }}</a>
{% endblock %}
</nav>
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
{%- block mobile_nav %}
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
{%- endblock %}
</nav>
{# PAGE CONTENT #}
<div class="wy-nav-content">
<div class="rst-content">
{% include "breadcrumbs.html" %}
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody" class="section">
{% block body %}{% endblock %}
</div>
<div class="articleComments">
{% block comments %}{% endblock %}
</div>
</div>
{% include "footer.html" %}
<div class="wy-nav-content">
{%- block content %}
{%- if theme_style_external_links|tobool %}
<div class="rst-content style-external-links">
{%- else %}
<div class="rst-content">
{%- endif %}
{% include "breadcrumbs.html" %}
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
{%- block document %}
<div itemprop="articleBody">
{% block body %}{% endblock %}
</div>
{%- if self.comments()|trim %}
<div class="articleComments">
{%- block comments %}{% endblock %}
</div>
{%- endif%}
</div>
{%- endblock %}
{% include "footer.html" %}
</div>
{%- endblock %}
</div>
</div>
</section>
</div>
{% include "versions.html" %}
{% if not embedded %}
<script type="text/javascript">
var DOCUMENTATION_OPTIONS = {
URL_ROOT:'{{ url_root }}',
VERSION:'{{ release|e }}',
COLLAPSE_INDEX:false,
FILE_SUFFIX:'{{ '' if no_search_suffix else file_suffix }}',
HAS_SOURCE: {{ has_source|lower }},
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
};
</script>
{%- for scriptfile in script_files %}
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
{%- endfor %}
{% endif %}
{# RTD hosts this file, so just load on non RTD builds #}
{% if not READTHEDOCS %}
<script type="text/javascript" src="{{ pathto('_static/js/theme.js', 1) }}"></script>
@@ -214,7 +198,7 @@
{% if theme_sticky_navigation %}
<script type="text/javascript">
jQuery(function () {
SphinxRtdTheme.StickyNav.enable();
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
});
</script>
{% endif %}
+184 -166
View File
@@ -1,136 +1,86 @@
{#
basic/layout.html
~~~~~~~~~~~~~~~~~
Master layout template for Sphinx themes.
:copyright: Copyright 2007-2013 by the Sphinx team, see AUTHORS.
:license: BSD, see LICENSE for details.
#}
{%- block doctype -%}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
{%- endblock %}
{%- set reldelim1 = reldelim1 is not defined and ' &raquo;' or reldelim1 %}
{%- set reldelim2 = reldelim2 is not defined and ' |' or reldelim2 %}
{%- set render_sidebar = (not embedded) and (not theme_nosidebar|tobool) and
(sidebars != []) %}
{# TEMPLATE VAR SETTINGS #}
{%- set url_root = pathto('', 1) %}
{# XXX necessary? #}
{%- if url_root == '#' %}{% set url_root = '' %}{% endif %}
{%- if not embedded and docstitle %}
{%- set titlesuffix = " &mdash; "|safe + docstitle|e %}
{%- else %}
{%- set titlesuffix = "" %}
{%- endif %}
{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %}
{%- macro relbar() %}
<div class="related">
<h3>{{ _('Navigation') }}</h3>
<ul>
{%- for rellink in rellinks %}
<li class="right" {% if loop.first %}style="margin-right: 10px"{% endif %}>
<a href="{{ pathto(rellink[0]) }}" title="{{ rellink[1]|striptags|e }}"
{{ accesskey(rellink[2]) }}>{{ rellink[3] }}</a>
{%- if not loop.first %}{{ reldelim2 }}{% endif %}</li>
{%- endfor %}
{%- block rootrellink %}
<li><a href="{{ pathto(master_doc) }}">{{ shorttitle|e }}</a>{{ reldelim1 }}</li>
{%- endblock %}
{%- for parent in parents %}
<li><a href="{{ parent.link|e }}" {% if loop.last %}{{ accesskey("U") }}{% endif %}>{{ parent.title }}</a>{{ reldelim1 }}</li>
{%- endfor %}
{%- block relbaritems %} {% endblock %}
</ul>
</div>
{%- endmacro %}
{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #}
{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%}
{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%}
{%- macro sidebar() %}
{%- if render_sidebar %}
<div class="sphinxsidebar">
<div class="sphinxsidebarwrapper">
{%- block sidebarlogo %}
{%- if logo %}
<p class="logo"><a href="{{ pathto(master_doc) }}">
<img class="logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/>
</a></p>
{%- endif %}
{%- endblock %}
{%- if sidebars != None %}
{#- new style sidebar: explicitly include/exclude templates #}
{%- for sidebartemplate in sidebars %}
{%- include sidebartemplate %}
{%- endfor %}
{%- else %}
{#- old style sidebars: using blocks -- should be deprecated #}
{%- block sidebartoc %}
{%- include "localtoc.html" %}
{%- endblock %}
{%- block sidebarrel %}
{%- include "relations.html" %}
{%- endblock %}
{%- block sidebarsourcelink %}
{%- include "sourcelink.html" %}
{%- endblock %}
{%- if customsidebar %}
{%- include customsidebar %}
{%- endif %}
{%- block sidebarsearch %}
{%- include "searchbox.html" %}
{%- endblock %}
{%- endif %}
</div>
</div>
{%- endif %}
{%- endmacro %}
<!DOCTYPE html>
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
<head>
<meta charset="utf-8" />
{%- if READTHEDOCS and not embedded %}
<meta name="readthedocs-addons-api-version" content="1">
{%- endif %}
{{- metatags }}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{%- block htmltitle %}
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
{%- endblock -%}
{%- macro script() %}
<script type="text/javascript">
var DOCUMENTATION_OPTIONS = {
URL_ROOT: '{{ url_root }}',
VERSION: '{{ release|e }}',
COLLAPSE_INDEX: false,
FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
HAS_SOURCE: {{ has_source|lower }},
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
};
</script>
{#- CSS #}
{%- for css_file in css_files %}
{%- if css_file|attr("filename") %}
{{ css_tag(css_file) }}
{%- else %}
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
{%- endif %}
{%- endfor %}
{#
"extra_css_files" is an undocumented Read the Docs theme specific option.
There is no need to check for ``|attr("filename")`` here because it's always a string.
Note that this option should be removed in favor of regular ``html_css_files``:
https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_css_files
#}
{%- for css_file in extra_css_files %}
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
{%- endfor -%}
{#- FAVICON #}
{%- if favicon_url %}
<link rel="shortcut icon" href="{{ favicon_url }}"/>
{%- endif %}
{#- CANONICAL URL (deprecated) #}
{%- if theme_canonical_url and not pageurl %}
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
{%- endif -%}
{#- CANONICAL URL #}
{%- if pageurl %}
<link rel="canonical" href="{{ pageurl|e }}" />
{%- endif -%}
{#- JAVASCRIPTS #}
{%- block scripts %}
{%- if not embedded %}
{%- for scriptfile in script_files %}
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
{{ js_tag(scriptfile) }}
{%- endfor %}
{%- endmacro %}
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
{%- macro css() %}
<link rel="stylesheet" href="{{ pathto('_static/' + style, 1) }}" type="text/css" />
<link rel="stylesheet" href="{{ pathto('_static/pygments.css', 1) }}" type="text/css" />
{%- for cssfile in css_files %}
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
{%- endfor %}
{%- endmacro %}
{%- if READTHEDOCS or DEBUG %}
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
{%- endif %}
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset={{ encoding }}" />
{{ metatags }}
{%- block htmltitle %}
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
{%- endblock %}
{{ css() }}
{%- if not embedded %}
{{ script() }}
{#- OPENSEARCH #}
{%- if use_opensearch %}
<link rel="search" type="application/opensearchdescription+xml"
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
{%- endif %}
{%- if favicon %}
<link rel="shortcut icon" href="{{ pathto('_static/' + favicon, 1) }}"/>
{%- endif %}
{%- if theme_canonical_url %}
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
{%- endif %}
{%- endif %}
{%- block linktags %}
{%- endif %}
{%- endblock %}
{%- block linktags %}
{%- if hasdoc('about') %}
<link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" />
{%- endif %}
@@ -143,67 +93,135 @@
{%- if hasdoc('copyright') %}
<link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" />
{%- endif %}
<link rel="top" title="{{ docstitle|e }}" href="{{ pathto('index') }}" />
{%- if parents %}
<link rel="up" title="{{ parents[-1].title|striptags|e }}" href="{{ parents[-1].link|e }}" />
{%- endif %}
{%- if next %}
<link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}" />
{%- endif %}
{%- if prev %}
<link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}" />
{%- endif %}
{%- endblock %}
{%- block extrahead %} {% endblock %}
</head>
<body>
{%- block header %}{% endblock %}
{%- block relbar1 %}{{ relbar() }}{% endblock %}
{%- block content %}
{%- block sidebar1 %} {# possible location for sidebar #} {% endblock %}
<div class="document">
{%- block document %}
<div class="documentwrapper">
{%- if render_sidebar %}
<div class="bodywrapper">
{%- endif %}
<div class="body">
{% block body %} {% endblock %}
</div>
{%- if render_sidebar %}
</div>
{%- endif %}
</div>
{%- endblock %}
{%- block extrahead %} {% endblock %}
</head>
{%- block sidebar2 %}{{ sidebar() }}{% endblock %}
<div class="clearer"></div>
</div>
{%- endblock %}
<body class="wy-body-for-nav">
{%- block relbar2 %}{{ relbar() }}{% endblock %}
{%- block extrabody %} {% endblock %}
<div class="wy-grid-for-nav">
{#- SIDE NAV, TOGGLES ON MOBILE #}
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
<div class="wy-side-scroll">
<div class="wy-side-nav-search" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
{%- block sidebartitle %}
{# the logo helper function was removed in Sphinx 6 and deprecated since Sphinx 4 #}
{# the master_doc variable was renamed to root_doc in Sphinx 4 (master_doc still exists in later Sphinx versions) #}
{%- set _logo_url = logo_url|default(pathto('_static/' + (logo or ""), 1)) %}
{%- set _root_doc = root_doc|default(master_doc) %}
<a href="{{ pathto(_root_doc) }}"{% if not theme_logo_only %} class="icon icon-home"{% endif %}>
{% if not theme_logo_only %}{{ project }}{% endif %}
{%- if logo or logo_url %}
<img src="{{ _logo_url }}" class="logo" alt="{{ _('Logo') }}"/>
{%- endif %}
</a>
{%- if READTHEDOCS or DEBUG %}
{%- if theme_version_selector or theme_language_selector %}
<div class="switch-menus">
<div class="version-switch"></div>
<div class="language-switch"></div>
</div>
{%- endif %}
{%- endif %}
{%- include "searchbox.html" %}
{%- endblock %}
</div>
{%- block navigation %}
{#- Translators: This is an ARIA section label for the main navigation menu -#}
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="{{ _('Navigation menu') }}">
{%- block menu %}
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int,
collapse=theme_collapse_navigation|tobool,
includehidden=theme_includehidden|tobool,
titles_only=theme_titles_only|tobool) %}
{%- if toctree %}
{{ toctree }}
{%- else %}
<!-- Local TOC -->
<div class="local-toc">{{ toc }}</div>
{%- endif %}
{%- endblock %}
</div>
{%- endblock %}
</div>
</nav>
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
{#- MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
{#- Translators: This is an ARIA section label for the navigation menu that is visible when viewing the page on mobile devices -#}
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
{%- block mobile_nav %}
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
{%- endblock %}
</nav>
<div class="wy-nav-content">
{%- block content %}
{%- if theme_style_external_links|tobool %}
<div class="rst-content style-external-links">
{%- else %}
<div class="rst-content">
{%- endif %}
{% include "breadcrumbs.html" %}
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
{%- block document %}
<div itemprop="articleBody">
{% block body %}{% endblock %}
</div>
{%- if self.comments()|trim %}
<div class="articleComments">
{%- block comments %}{% endblock %}
</div>
{%- endif%}
</div>
{%- endblock %}
{% include "footer.html" %}
</div>
{%- endblock %}
</div>
</section>
</div>
{% include "versions.html" -%}
<script>
jQuery(function () {
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
});
</script>
{#- Do not conflict with RTD insertion of analytics script #}
{%- if not READTHEDOCS %}
{%- if theme_analytics_id %}
<!-- Theme Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id={{ theme_analytics_id }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ theme_analytics_id }}', {
'anonymize_ip': {{ 'true' if theme_analytics_anonymize_ip|tobool else 'false' }},
});
</script>
{%- block footer %}
<div class="footer">
{%- if show_copyright %}
{%- if hasdoc('copyright') %}
{% trans path=pathto('copyright'), copyright=copyright|e %}&copy; <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %}
{%- else %}
{% trans copyright=copyright|e %}&copy; Copyright {{ copyright }}.{% endtrans %}
{%- endif %}
{%- endif %}
{%- if last_updated %}
{% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %}
{%- endif %}
{%- if show_sphinx %}
{% trans sphinx_version=sphinx_version|e %}Created using <a href="http://sphinx-doc.org/">Sphinx</a> {{ sphinx_version }}.{% endtrans %}
{%- endif %}
</div>
<p>asdf asdf asdf asdf 22</p>
{%- endblock %}
</body>
</html>
{%- endif %}
{%- block footer %} {% endblock %}
</body>
</html>
+1 -1
View File
@@ -39,7 +39,7 @@ as well as the type of underlying hardware. Example:
"rsa_pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh…nswIDAQAB\n-----END PUBLIC KEY-----\n"
}
The ``rsa_pubkey`` is optional any only required for certain fatures such as working with reusable
The ``rsa_pubkey`` is optional any only required for certain features such as working with reusable
media and NFC cryptography.
Every initialization token can only be used once. On success, you will receive a response containing
+1 -1
View File
@@ -117,7 +117,7 @@ List-level conditional fetching
If modification checks are not possible with this granularity, you can instead check for the full list.
In this case, the list of objects may contain a regular HTTP header ``Last-Modified`` with the date of the
last modification to any item of that resource. You can then pass this date back in your next request in the
``If-Modified-Since`` header. If the any object has changed in the meantime, you will receive back a full list
``If-Modified-Since`` header. If any object has changed in the meantime, you will receive back a full list
(if something it missing, this means the object has been deleted). If nothing happened, we'll send back a
``304 Not Modified`` return code.
+91
View File
@@ -421,3 +421,94 @@ Annulment of a check-in
: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.
Check-in history
----------------
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the check-in
successful boolean Whether the check-in was successful
error_reason string Category of reason why the check-in was unsuccessful. Currently
``"canceled"``, ``"invalid"``, ``"unpaid"`` ``"product"``,
``"rules"``, ``"revoked"``, ``"incomplete"``, ``"already_redeemed"``,
``"ambiguous"``, ``"error"``, ``"blocked"``, ``"unapproved"``,
``"invalid_time"``, ``"annulled"`` or ``null``
error_explanation string Additional, human-readable reason for the check-in to be unsuccessful (or ``null``)
position integer Internal ID of the order position (or ``null`` for unknown scans)
datetime datetime Logical time when the check-in happened
created datetime Time when the check-in appeared on the server
list integer Internal ID of the check-in list
auto_checked_in boolean Whether the check-in was performed by the system automatically
gate integer Internal ID of the gate (or ``null``)
device integer Internal ID of the device (or ``null``)
device_id integer Organizer-internal ID of the device (or ``null``)
type string Type of check-in, currently ``"entry"`` or ``"exit"``
===================================== ========================== =======================================================
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkins/
Returns a list of all check-in events within a given event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/checkins/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"successful": true,
"error_reason": null,
"error_explanation": null,
"position": 1234,
"datetime": "2017-12-25T12:45:23Z",
"created": "2017-12-25T12:45:23Z",
"list": 2,
"auto_checked_in": false,
"gate": null,
"device": null,
"device_id": null,
"type": "entry",
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query datetime created_since: Only return check-ins that have been created since the given date (inclusive).
:query datetime created_before: Only return check-ins that have been created before the given date (exclusive).
:query datetime datetime_since: Only return check-ins that have happened since the given date (inclusive).
:query datetime datetime_before: Only return check-ins that have happened before the given date (exclusive).
:query boolean successful: Only return check-ins that have (not) been successful.
:query boolean error_reason: Only return check-ins with a specific error reason.
:query integer list: Only return check-ins from a specific list.
:query string type: Only return check-ins of a specific type.
:query integer gate: Only return check-ins from a specific gate.
:query integer device: Only return check-ins from a specific device.
:query boolean auto_checked_in: Only return check-ins that are (not) auto-checked in.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``created``,
and ``id``.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
+1
View File
@@ -19,6 +19,7 @@ at :ref:`plugin-docs`.
item_bundles
item_add-ons
item_meta_properties
item_program_times
questions
question_options
quotas
+3
View File
@@ -22,6 +22,7 @@ invoice_from_name string Sender address:
invoice_from string Sender address: Address lines
invoice_from_zipcode string Sender address: ZIP code
invoice_from_city string Sender address: City
invoice_from_state string Sender address: State (only used in some countries)
invoice_from_country string Sender address: Country code
invoice_from_tax_id string Sender address: Local Tax ID
invoice_from_vat_id string Sender address: EU VAT ID
@@ -233,6 +234,7 @@ List of all invoices
"invoice_from": "Demo street 12",
"invoice_from_zipcode":"",
"invoice_from_city":"Demo town",
"invoice_from_state":"CA",
"invoice_from_country":"US",
"invoice_from_tax_id":"",
"invoice_from_vat_id":"",
@@ -381,6 +383,7 @@ Fetching individual invoices
"invoice_from": "Demo street 12",
"invoice_from_zipcode":"",
"invoice_from_city":"Demo town",
"invoice_from_state":"CA",
"invoice_from_country":"US",
"invoice_from_tax_id":"",
"invoice_from_vat_id":"",
+223
View File
@@ -0,0 +1,223 @@
Item program times
==================
Resource description
--------------------
Program times for products (items) that can be set in addition to event times, e.g. to display seperate schedules within an event.
Note that ``program_times`` are not available for items inside event series.
The program times resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the program time
start datetime The start date time for this program time slot.
end datetime The end date time for this program time slot.
===================================== ========================== =======================================================
.. versionchanged:: TODO
The resource has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
Returns a list of all program times for a given item.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/program_times/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 3,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
},
{
"id": 3,
"start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z"
},
{
"id": 14,
"start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z"
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param item: The ``id`` field of the item to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
Returns information on one program time, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"start": "2025-08-15T22:00:00Z",
"end": "2025-10-27T23:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param item: The ``id`` field of the item to fetch
:param id: The ``id`` field of the program time to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
Creates a new program time
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 17,
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z"
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
:param event: The ``slug`` field of the event to create a program time for
:param item: The ``id`` field of the item to create a program time for
:statuscode 201: no error
:statuscode 400: The program time could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
Update a program time. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"start": "2025-08-14T10:00:00Z"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"start": "2025-08-14T10:00:00Z",
"end": "2025-08-15T12:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the item to modify
:param id: The ``id`` field of the program time to modify
:statuscode 200: no error
:statuscode 400: The program time could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/program_times/(id)/
Delete a program time.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the item to modify
:param id: The ``id`` field of the program time to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
+36 -10
View File
@@ -139,6 +139,10 @@ has_variations boolean Shows whether
variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
program_times list of objects A list with one object for each program time of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
Not available for items in event series.
├ id integer Internal ID of the variation
├ value multi-lingual string The "name" of the variation
├ default_price money (string) The price set directly for this variation or ``null``
@@ -225,6 +229,10 @@ meta_data object Values set fo
The ``hidden_if_item_available_mode`` attributes has been added.
.. versionchanged:: 2025.9
The ``program_times`` attribute has been added.
Notes
-----
@@ -232,9 +240,11 @@ Please note that an item either always has variations or never has. Once created
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
one variation.
Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations,
bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
with nested ``variations``, ``bundles`` and/or ``addons``.
Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` are only supported on ``POST``. To update/delete variations,
bundles, add-ons and program times please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
with nested ``variations``, ``bundles``, ``addons`` and/or ``program_times``.
``program_times`` is not available to items in event series.
Endpoints
---------
@@ -373,7 +383,8 @@ Endpoints
}
],
"addons": [],
"bundles": []
"bundles": [],
"program_times": []
}
]
}
@@ -525,7 +536,8 @@ Endpoints
}
],
"addons": [],
"bundles": []
"bundles": [],
"program_times": []
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -653,7 +665,13 @@ Endpoints
}
],
"addons": [],
"bundles": []
"bundles": [],
"program_times": [
{
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
}
]
}
**Example response**:
@@ -773,7 +791,13 @@ Endpoints
}
],
"addons": [],
"bundles": []
"bundles": [],
"program_times": [
{
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
}
]
}
:param organizer: The ``slug`` field of the organizer of the event to create an item for
@@ -789,8 +813,9 @@ Endpoints
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If
you need to update/delete variations or add-ons please use the nested dedicated endpoints.
You can change all fields of the resource except the ``has_variations``, ``variations``, ``addon`` and the
``program_times`` field. If you need to update/delete variations, add-ons or program times, please use the nested
dedicated endpoints.
**Example request**:
@@ -924,7 +949,8 @@ Endpoints
}
],
"addons": [],
"bundles": []
"bundles": [],
"program_times": []
}
:param organizer: The ``slug`` field of the organizer to modify
+8
View File
@@ -41,6 +41,7 @@ expires datetime The order will
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
total money (string) Total value of this order
tax_rounding_mode string Tax rounding mode, see :ref:`algorithms-rounding`
comment string Internal comment on this order
api_meta object Meta data for that order. Only available through API, no guarantees
on the content structure. You can use this to save references to your system.
@@ -151,6 +152,10 @@ plugin_data object Additional data
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
.. versionchanged:: 2025.10
The ``tax_rounding_mode`` attribute has been added.
.. _order-position-resource:
Order position resource
@@ -358,6 +363,7 @@ List of all orders
"payment_provider": "banktransfer",
"fees": [],
"total": "23.00",
"tax_rounding_mode": "line",
"comment": "",
"custom_followup_at": null,
"checkin_attention": false,
@@ -602,6 +608,7 @@ Fetching individual orders
"payment_provider": "banktransfer",
"fees": [],
"total": "23.00",
"tax_rounding_mode": "line",
"comment": "",
"api_meta": {},
"custom_followup_at": null,
@@ -1011,6 +1018,7 @@ Creating orders
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
charge will be created), this is just informative in case you *handled the payment already*.
* ``payment_date`` (optional) Date and time of the completion of the payment.
* ``tax_rounding_mode`` (optional)
* ``comment`` (optional)
* ``custom_followup_at`` (optional)
* ``checkin_attention`` (optional)
+121
View File
@@ -178,3 +178,124 @@ Flowchart
---------
.. image:: /images/cart_pricing.png
.. _`algorithms-rounding`:
Rounding of taxes
-----------------
pretix internally always stores taxes on a per-line level, like this:
========== ========== =========== ======= =============
Product Tax rate Net price Tax Gross price
========== ========== =========== ======= =============
Ticket A 19 % 84.03 15.97 100.00
Ticket B 19 % 84.03 15.97 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 79.85 500.00
========== ========== =========== ======= =============
Whether the net price is computed from the gross price or vice versa is configured on the tax rule and may differ for every line.
The line-based computation has a few significant advantages:
- We can report both net and gross prices for every individual ticket.
- We can report both net and gross prices for every filter imaginable, such as the gross sum of all sales of Ticket A
or the net sum of all sales for a specific date in an event series. All numbers will be exact.
- When splitting the order into two, both net price and gross price are split without any changes in rounding.
The main disadvantage is that the tax looks "wrong" when computed from the sum. Taking the sum of net prices (420.15)
and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98
(instead of 500.00). This becomes a problem when juristictions, data formats, or external systems expect this calculation
to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that
does not allow the computation as created by pretix.
However, calculating the tax rate from the net total has significant disadvantages:
- It is impossible to guarantee a stable gross price this way, i.e. if you advertise a price of €100 per ticket to
consumers, they will be confused when they only need to pay €499.98 for 5 tickets.
- Some prices are impossible, e.g. you cannot sell a ticket for a gross price of €99.99 at a 19% tax rate, since there
is no two-decimal net price that would be computed to a gross price of €99.99.
- When splitting an order into two, the combined of the new orders is not guaranteed to be the same as the total of the
original order. Therefore, additional payments or refunds of very small amounts might be necessary.
To allow organizers to make their own choices on this matter, pretix provides the following options:
Compute taxes for every line individually
"""""""""""""""""""""""""""""""""""""""""
Algorithm identifier: ``line``
This is our original algorithm where the tax value is rounded for every line individually.
**This is our current default algorithm and we recommend it whenever you do not have different requirements** (see below).
For the example above:
========== ========== =========== ======= =============
Product Tax rate Net price Tax Gross price
========== ========== =========== ======= =============
Ticket A 19 % 84.03 15.97 100.00
Ticket B 19 % 84.03 15.97 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 79.85 500.00
========== ========== =========== ======= =============
Compute taxes based on net total
""""""""""""""""""""""""""""""""
Algorithm identifier: ``sum_by_net``
In this algorithm, the tax value and gross total are computed from the sum of the net prices. To accomplish this within
our data model, the gross price and tax of some of the tickets will be changed by the minimum currency unit (e.g. €0.01).
The net price of the tickets always stay the same.
**This is the algorithm intended by EN 16931 invoices and our recommendation to use for e-invoicing when (primarily) business customers are involved.**
The main downside is that it might be confusing when selling to consumers, since the amounts to be paid change in unexpected ways.
For the example above, the customer expects to pay 5 times 100.00, but they are are in fact charged 499.98:
========== ========== =========== ============================== ==============================
Product Tax rate Net price Tax Gross price
========== ========== =========== ============================== ==============================
Ticket A 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
Ticket B 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 78.83 499.98
========== ========== =========== ============================== ==============================
Compute taxes based on net total with stable gross prices
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
Algorithm identifier: ``sum_by_net_keep_gross``
In this algorithm, the tax value and gross total are computed from the sum of the net prices. However, the net prices
of some of the tickets will be changed automatically by the minimum currency unit (e.g. €0.01) such that the resulting
gross prices stay the same.
**This is less confusing to consumers and the end result is still compliant to EN 16931, so we recommend this for e-invoicing when (primarily) consumers are involved.**
The main downside is that it might be confusing when selling to business customers, since the prices of the identical tickets appear to be different.
Full computation for the example above:
========== ========== ============================= ============================== =============
Product Tax rate Net price Tax Gross price
========== ========== ============================= ============================== =============
Ticket A 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
Ticket B 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.17 79.83 500.00
========== ========== ============================= ============================== =============
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+1
View File
@@ -23,6 +23,7 @@ partition "For every cart position" {
--> "Store as line_price (gross), tax_rate"
}
--> "Apply discount engine"
--> "Apply tax rounding"
--> "Store as price (gross)"
@enduml
+7 -8
View File
@@ -1,9 +1,8 @@
sphinx==7.4.*
jinja2==3.1.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
sphinxcontrib-images
sphinxcontrib-jquery
sphinxcontrib-spelling==8.*
sphinxemoji
sphinx==9.1.*
sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=1.8.1
sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2
sphinxemoji~=0.3.2
pyenchant==3.3.*
+7 -8
View File
@@ -1,10 +1,9 @@
-e ../
sphinx==7.4.*
jinja2==3.1.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
sphinxcontrib-images
sphinxcontrib-jquery
sphinxcontrib-spelling==8.*
sphinxemoji
sphinx==9.1.*
sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=1.8.1
sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2
sphinxemoji~=0.3.2
pyenchant==3.3.*
+21 -21
View File
@@ -3,7 +3,7 @@ name = "pretix"
dynamic = ["version"]
description = "Reinventing presales, one ticket at a time"
readme = "README.rst"
requires-python = ">=3.9"
requires-python = ">=3.10"
license = {file = "LICENSE"}
keywords = ["tickets", "web", "shop", "ecommerce"]
authors = [
@@ -29,16 +29,17 @@ dependencies = [
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
"babel",
"BeautifulSoup4==4.14.*",
"bleach==6.2.*",
"celery==5.5.*",
"bleach==6.3.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=44.0.0",
"css-inline==0.17.*",
"css-inline==0.19.*",
"defusedcsv>=1.1.0",
"Django[argon2]==4.2.*,>=4.2.24",
"django-bootstrap3==25.2",
"django-compressor==4.5.1",
"django-countries==7.6.*",
"dnspython==2.*",
"Django[argon2]==4.2.*,>=4.2.26",
"django-bootstrap3==26.1",
"django-compressor==4.6.0",
"django-countries==8.2.*",
"django-filter==25.1",
"django-formset-js-improved==0.5.0.4",
"django-formtools==2.5.1",
@@ -49,22 +50,22 @@ dependencies = [
"django-localflavor==5.0",
"django-markup",
"django-oauth-toolkit==2.3.*",
"django-otp==1.6.*",
"django-phonenumber-field==7.3.*",
"django-otp==1.7.*",
"django-phonenumber-field==8.4.*",
"django-redis==6.0.*",
"django-scopes==2.0.*",
"django-statici18n==2.6.*",
"djangorestframework==3.16.*",
"dnspython==2.7.*",
"dnspython==2.8.*",
"drf_ujson2==1.7.*",
"geoip2==5.*",
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek",
"jsonschema",
"kombu==5.5.*",
"kombu==5.6.*",
"libsass==0.23.*",
"lxml",
"markdown==3.9", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
"markdown==3.10", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.3.*",
@@ -74,31 +75,30 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.10.*",
"phonenumberslite==9.0.*",
"Pillow==11.3.*",
"Pillow==12.1.*",
"pretix-plugin-build",
"protobuf==6.33.*",
"psycopg2-binary",
"pycountry",
"pycparser==2.23",
"pycryptodome==3.23.*",
"pypdf==6.1.*",
"pypdf==6.5.*",
"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.4.*",
"redis==7.1.*",
"reportlab==4.4.*",
"requests==2.32.*",
"sentry-sdk==2.42.*",
"sentry-sdk==2.49.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
"tlds>=2020041600",
"tqdm==4.*",
"ua-parser==1.0.*",
"vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*",
"webauthn==2.7.*",
"zeep==4.3.*"
@@ -110,10 +110,10 @@ dev = [
"aiohttp==3.13.*",
"coverage",
"coveralls",
"fakeredis==2.32.*",
"fakeredis==2.33.*",
"flake8==7.3.*",
"freezegun",
"isort==6.1.*",
"isort==7.0.*",
"pep8-naming==0.15.*",
"potypo",
"pytest-asyncio>=0.24",
@@ -123,7 +123,7 @@ dev = [
"pytest-mock==3.15.*",
"pytest-sugar",
"pytest-xdist==3.8.*",
"pytest==8.4.*",
"pytest==9.0.*",
"responses",
]
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2025.9.0"
__version__ = "2025.11.0.dev0"
@@ -0,0 +1,23 @@
# Generated by Django 4.2.24 on 2025-11-14 16:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixapi", "0013_alter_webhookcallretry_retry_not_before"),
]
operations = [
migrations.AlterField(
model_name="webhook",
name="target_url",
field=models.URLField(max_length=1024),
),
migrations.AlterField(
model_name="webhookcall",
name="target_url",
field=models.URLField(max_length=1024),
),
]
+2 -2
View File
@@ -114,7 +114,7 @@ class OAuthRefreshToken(AbstractRefreshToken):
class WebHook(models.Model):
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
target_url = models.URLField(verbose_name=_("Target URL"), max_length=255)
target_url = models.URLField(verbose_name=_("Target URL"), max_length=1024)
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
comment = models.CharField(verbose_name=_("Comment"), max_length=255, null=True, blank=True)
@@ -140,7 +140,7 @@ class WebHookEventListener(models.Model):
class WebHookCall(models.Model):
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
datetime = models.DateTimeField(auto_now_add=True)
target_url = models.URLField(max_length=255)
target_url = models.URLField(max_length=1024)
action_type = models.CharField(max_length=255)
is_retry = models.BooleanField(default=False)
execution_time = models.FloatField(null=True)
+5
View File
@@ -795,6 +795,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_address_asked',
'invoice_address_required',
'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required',
'invoice_address_beneficiary',
'invoice_address_custom_field',
@@ -820,6 +821,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_address_from',
'invoice_address_from_zipcode',
'invoice_address_from_city',
'invoice_address_from_state',
'invoice_address_from_country',
'invoice_address_from_tax_id',
'invoice_address_from_vat_id',
@@ -829,6 +831,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_eu_currencies',
'invoice_logo_image',
'invoice_renderer_highlight_order_code',
'tax_rounding',
'cancel_allow_user',
'cancel_allow_user_until',
'cancel_allow_user_unpaid_keep',
@@ -941,6 +944,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'invoice_address_asked',
'invoice_address_required',
'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required',
'invoice_address_beneficiary',
'invoice_address_custom_field',
@@ -951,6 +955,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'invoice_address_from',
'invoice_address_from_zipcode',
'invoice_address_from_city',
'invoice_address_from_state',
'invoice_address_from_country',
'invoice_address_from_tax_id',
'invoice_address_from_vat_id',
+55 -6
View File
@@ -47,8 +47,9 @@ from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel,
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
)
@@ -187,6 +188,12 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
'position', 'price_included', 'multi_allowed')
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('start', 'end')
class ItemBundleSerializer(serializers.ModelSerializer):
class Meta:
model = ItemBundle
@@ -212,6 +219,37 @@ class ItemBundleSerializer(serializers.ModelSerializer):
return data
class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('id', 'start', 'end')
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
start = full_data.get('start')
if not start:
raise ValidationError(_("The program start must not be empty."))
end = full_data.get('end')
if not end:
raise ValidationError(_("The program end must not be empty."))
if start > end:
raise ValidationError(_("The program end must not be before the program start."))
event = self.context['event']
if event.has_subevents:
raise ValidationError({
_("You cannot use program times on an event series.")
})
return data
class ItemAddOnSerializer(serializers.ModelSerializer):
class Meta:
model = ItemAddOn
@@ -250,6 +288,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True, required=False)
bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False)
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
@@ -271,7 +310,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
'addons', 'bundles', 'program_times', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist',
'issue_giftcard', 'meta_data',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
@@ -294,9 +333,9 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
def validate(self, data):
data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the '
'dedicated nested endpoint.'))
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data or 'program_times' in data):
raise ValidationError(_('Updating add-ons, bundles, program times or variations via PATCH/PUT is not '
'supported. Please use the dedicated nested endpoint.'))
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until'))
@@ -347,6 +386,13 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
return value
def validate_program_times(self, value):
if not self.instance:
for program_time_data in value:
ItemProgramTime.clean_start_end(self, start=program_time_data.get('start', None),
end=program_time_data.get('end', None))
return value
@cached_property
def item_meta_properties(self):
return {
@@ -364,6 +410,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
program_times_data = validated_data.pop('program_times') if 'program_times' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', [])
@@ -398,6 +445,8 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
ItemAddOn.objects.create(base_item=item, **addon_data)
for bundle_data in bundles_data:
ItemBundle.objects.create(base_item=item, **bundle_data)
for program_time_data in program_times_data:
ItemProgramTime.objects.create(item=item, **program_time_data)
# Meta data
if meta_data is not None:
+61 -16
View File
@@ -52,9 +52,10 @@ 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,
ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher,
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
Voucher,
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -64,10 +65,13 @@ from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, is_included_for_free,
apply_discounts, apply_rounding, get_line_price, get_listed_price,
is_included_for_free,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
)
from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -325,7 +329,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
return data
class CheckinSerializer(I18nAwareModelSerializer):
class InlineCheckinSerializer(I18nAwareModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
slug_field='device_id',
@@ -337,6 +341,21 @@ class CheckinSerializer(I18nAwareModelSerializer):
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type')
class CheckinSerializer(I18nAwareModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
slug_field='device_id',
read_only=True,
)
class Meta:
model = Checkin
fields = (
'id', 'successful', 'error_reason', 'error_explanation', 'position', 'datetime', 'list', 'created',
'auto_checked_in', 'gate', 'device', 'device_id', 'type'
)
class PrintLogSerializer(serializers.ModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
@@ -560,7 +579,7 @@ class OrderPositionPluginDataField(serializers.Field):
class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True, read_only=True)
checkins = InlineCheckinSerializer(many=True, read_only=True)
print_logs = PrintLogSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True)
downloads = PositionDownloadsField(source='*', read_only=True)
@@ -833,14 +852,15 @@ class OrderSerializer(I18nAwareModelSerializer):
list_serializer_class = OrderListSerializer
fields = (
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data',
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address',
'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds',
'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date',
'plugin_data',
)
read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date'
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date',
)
def __init__(self, *args, **kwargs):
@@ -1159,6 +1179,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
queryset=SalesChannel.objects.none(),
required=False,
)
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
def __init__(self, *args, **kwargs):
@@ -1175,7 +1196,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
'require_approval', 'valid_if_pending', 'expires', 'api_meta')
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
def validate_payment_provider(self, pp):
if pp is None:
@@ -1580,7 +1601,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
cp.addon_to, cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
@@ -1701,7 +1722,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
f.save()
order.total += sum([f.value for f in fees])
rounding_mode = validated_data.get("tax_rounding_mode")
if not rounding_mode:
if isinstance(self.context.get("auth"), Device):
# Safety fallback to avoid differences in tax reporting
brand = self.context.get("auth").software_brand or ""
if "pretixPOS" in brand or "pretixKIOSK" in brand:
rounding_mode = "line"
if not rounding_mode:
rounding_mode = self.context["event"].settings.tax_rounding
changed = apply_rounding(
rounding_mode,
self.context["event"].currency,
[*pos_map.values(), *fees]
)
for line in changed:
if isinstance(line, OrderPosition):
line.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(line, OrderFee):
line.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees])
if simulate:
order.fees = fees
order.positions = pos_map.values()
@@ -1786,7 +1831,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
class Meta:
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_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'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',
+8 -8
View File
@@ -33,7 +33,7 @@ from pretix.api.serializers.order import (
OrderFeeCreateSerializer, OrderPositionCreateSerializer,
)
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderError
from pretix.base.services.orders import OrderChangeManager, OrderError
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
logger = logging.getLogger(__name__)
@@ -82,11 +82,11 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
return data
def create(self, validated_data):
ocm = self.context['ocm']
ocm: OrderChangeManager = self.context['ocm']
check_quotas = self.context.get('check_quotas', True)
try:
ocm.add_position(
new_position = ocm.add_position(
item=validated_data['item'],
variation=validated_data.get('variation'),
price=validated_data.get('price'),
@@ -98,7 +98,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
)
if self.context.get('commit', True):
ocm.commit(check_quotas=check_quotas)
return validated_data['order'].positions.order_by('-positionid').first()
return new_position.position
else:
return OrderPosition() # fake to appease DRF
except OrderError as e:
@@ -131,7 +131,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
return data
def create(self, validated_data):
ocm = self.context['ocm']
ocm: OrderChangeManager = self.context['ocm']
try:
f = OrderFee(
@@ -146,7 +146,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
ocm.add_fee(f)
if self.context.get('commit', True):
ocm.commit()
return validated_data['order'].fees.order_by('-pk').first()
return f
else:
return OrderFee() # fake to appease DRF
except OrderError as e:
@@ -310,7 +310,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
return data
def update(self, instance, validated_data):
ocm = self.context['ocm']
ocm: OrderChangeManager = 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)
@@ -399,7 +399,7 @@ class OrderFeeChangeSerializer(serializers.ModelSerializer):
)
def update(self, instance, validated_data):
ocm = self.context['ocm']
ocm: OrderChangeManager = self.context['ocm']
value = validated_data.get('value', instance.value)
try:
+1
View File
@@ -443,6 +443,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'imprint_url',
+2
View File
@@ -92,6 +92,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'seats', event.SeatViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'checkins', checkin.CheckinViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet)
event_router.register(r'scheduled_exports', exporters.ScheduledEventExportViewSet)
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
@@ -111,6 +112,7 @@ item_router = routers.DefaultRouter()
item_router.register(r'variations', item.ItemVariationViewSet)
item_router.register(r'addons', item.ItemAddOnViewSet)
item_router.register(r'bundles', item.ItemBundleViewSet)
item_router.register(r'program_times', item.ItemProgramTimeViewSet)
order_router = routers.DefaultRouter()
order_router.register(r'payments', order.PaymentViewSet)
+34 -1
View File
@@ -56,7 +56,8 @@ from pretix.api.serializers.checkin import (
)
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import (
CheckinListOrderPositionSerializer, FailedCheckinSerializer,
CheckinListOrderPositionSerializer, CheckinSerializer,
FailedCheckinSerializer,
)
from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter
@@ -96,6 +97,16 @@ with scopes_disabled():
)
return queryset.filter(expr)
class CheckinFilter(FilterSet):
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt')
datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt')
class Meta:
model = Checkin
fields = ['successful', 'error_reason', 'list', 'type', 'gate', 'device', 'auto_checked_in']
class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer
@@ -1080,3 +1091,25 @@ class CheckinRPCAnnulView(views.APIView):
checkin_annulled.send(ci.position.order.event, checkin=ci)
return Response({"status": "ok"}, status=status.HTTP_200_OK)
class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinSerializer
queryset = Checkin.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filterset_class = CheckinFilter
ordering = ('created', 'id')
ordering_fields = ('created', 'datetime', 'id',)
permission = 'can_view_orders'
def get_queryset(self):
qs = Checkin.all.filter().select_related(
"position",
"device",
)
return qs
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
+7 -1
View File
@@ -74,6 +74,11 @@ class ExportersMixin:
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
def download(self, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
if not cf.allowed_for_session(self.request, "exporters-api"):
return Response(
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
status=status.HTTP_410_GONE
)
if cf.file:
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
@@ -109,7 +114,8 @@ class ExportersMixin:
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
serializer.is_valid(raise_exception=True)
cf = CachedFile(web_download=False)
cf = CachedFile(web_download=True)
cf.bind_to_session(self.request, "exporters-api")
cf.date = now()
cf.expires = now() + timedelta(hours=24)
cf.save()
+60 -7
View File
@@ -40,19 +40,19 @@ from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer,
QuestionSerializer, QuotaSerializer,
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
Question, QuestionOption, Quota,
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.dicts import merge_dicts
@@ -106,7 +106,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
'variations__meta_values', 'variations__meta_values__property',
'require_membership_types', 'variations__require_membership_types',
'limit_sales_channels', 'variations__limit_sales_channels',
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
).all()
def perform_create(self, serializer):
@@ -279,6 +279,59 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
)
class ItemProgramTimeViewSet(viewsets.ModelViewSet):
serializer_class = ItemProgramTimeSerializer
queryset = ItemProgramTime.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter,)
ordering_fields = ('id',)
ordering = ('id',)
permission = None
write_permission = 'can_change_items'
@cached_property
def item(self):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
if self.request.event.has_subevents:
raise ValidationError('You cannot use program times on an event series.')
return self.item.program_times.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['item'] = self.item
return ctx
def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(item=item)
item.log_action(
'pretix.event.item.program_times.added',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.item.log_action(
'pretix.event.item.program_times.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
def perform_destroy(self, instance):
super().perform_destroy(instance)
instance.item.log_action(
'pretix.event.item.program_times.removed',
user=self.request.user,
auth=self.request.auth,
data={'start': instance.start, 'end': instance.end}
)
class ItemAddOnViewSet(viewsets.ModelViewSet):
serializer_class = ItemAddOnSerializer
queryset = ItemAddOn.objects.none()
@@ -514,7 +567,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.quotas.all()
return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all()
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset()).distinct()
+1
View File
@@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['auth'] = self.request.auth
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
return ctx
+1 -1
View File
@@ -721,7 +721,7 @@ class MembershipViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Membership.objects.filter(
customer__organizer=self.request.organizer
)
).select_related('customer')
def get_serializer_context(self):
ctx = super().get_serializer_context()
+7 -1
View File
@@ -19,6 +19,7 @@
# 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.db import transaction
from django.db.models import F, Q
from django.utils.timezone import now
@@ -64,8 +65,13 @@ class VoucherViewSet(viewsets.ModelViewSet):
permission = 'can_view_vouchers'
write_permission = 'can_change_vouchers'
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
def get_queryset(self):
return self.request.event.vouchers.select_related('seat').all()
return Voucher.annotate_budget_used(
self.request.event.vouchers
).select_related(
'item', 'quota', 'seat', 'variation'
)
@transaction.atomic()
def create(self, request, *args, **kwargs):
+5 -1
View File
@@ -43,6 +43,7 @@ from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import periodic_task
from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.celery import get_task_priority
logger = logging.getLogger(__name__)
_ALL_EVENTS = None
@@ -474,7 +475,10 @@ def notify_webhooks(logentry_ids: list):
)
for wh in webhooks:
send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk))
send_webhook.apply_async(
args=(logentry.id, notification_type.action_type, wh.pk),
priority=get_task_priority("notifications", logentry.organizer_id),
)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=60, acks_late=True, autoretry_for=(DatabaseError,),)
-17
View File
@@ -112,23 +112,6 @@ def oidc_validate_and_complete_config(config):
scope="openid",
))
for scope in config["scope"].split(" "):
if scope not in provider_config.get("scopes_supported", []):
raise ValidationError(_('You are requesting scope "{scope}" but provider only supports these: {scopes}.').format(
scope=scope,
scopes=", ".join(provider_config.get("scopes_supported", []))
))
if "claims_supported" in provider_config:
claims_supported = provider_config.get("claims_supported", [])
for k, v in config.items():
if k.endswith('_field') and v:
if v not in claims_supported: # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format(
field=v,
fields=", ".join(provider_config.get("claims_supported", []))
))
if "token_endpoint_auth_methods_supported" in provider_config:
token_endpoint_auth_methods_supported = provider_config.get("token_endpoint_auth_methods_supported",
["client_secret_basic"])
+3 -1
View File
@@ -90,6 +90,7 @@ StaticMapping = namedtuple('StaticMapping', ('id', 'pretix_model', 'external_obj
class OutboundSyncProvider:
max_attempts = 5
list_field_joiner = "," # set to None to keep native lists in properties
def __init__(self, event):
self.event = event
@@ -281,7 +282,8 @@ class OutboundSyncProvider:
'Please update value mapping for field "{field_name}" - option "{val}" not assigned'
).format(field_name=key, val=val)])
val = ",".join(val)
if self.list_field_joiner:
val = self.list_field_joiner.join(val)
return val
def get_properties(self, inputs: dict, property_mappings: List[dict]):
+12 -7
View File
@@ -71,15 +71,20 @@ def assign_properties(
return out
def _add_to_list(out, field_name, current_value, new_item, list_sep):
new_item = str(new_item)
def _add_to_list(out, field_name, current_value, new_item_input, list_sep):
if list_sep is not None:
new_item = new_item.replace(list_sep, "")
new_items = str(new_item_input).split(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]
else:
new_items = [str(new_item_input)]
if not isinstance(current_value, (list, tuple)):
current_value = [str(current_value)]
new_list = list(current_value)
for new_item in new_items:
if new_item not in current_value:
new_list.append(new_item)
if new_list != current_value:
if list_sep is not None:
new_list = list_sep.join(new_list)
out[field_name] = new_list
+20 -5
View File
@@ -24,6 +24,7 @@ from itertools import groupby
from smtplib import SMTPResponseException
from typing import TypeVar
import bleach
import css_inline
from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend
@@ -34,7 +35,10 @@ from django.utils.translation import get_language, gettext_lazy as _
from pretix.base.models import Event
from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.templatetags.rich_text import (
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
markdown_compile_email, truelink_callback,
)
from pretix.helpers.format import SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa
@@ -133,13 +137,24 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self):
raise NotImplementedError()
def compile_markdown(self, plaintext):
return markdown_compile_email(plaintext)
def compile_markdown(self, plaintext, context=None):
return markdown_compile_email(plaintext, context=context)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
body_md = self.compile_markdown(plain_body)
body_md = self.compile_markdown(plain_body, context)
if context:
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
linker = bleach.Linker(
url_re=URL_RE,
email_re=EMAIL_RE,
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
parse_email=True
)
body_md = format_map(
body_md,
context=context,
mode=SafeFormatter.MODE_RICH_TO_HTML,
linkifier=linker
)
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
+2
View File
@@ -209,6 +209,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Invoice sender:') + ' ' + _('Address'),
_('Invoice sender:') + ' ' + _('ZIP code'),
_('Invoice sender:') + ' ' + _('City'),
_('Invoice sender:') + ' ' + pgettext('address', 'State'),
_('Invoice sender:') + ' ' + _('Country'),
_('Invoice sender:') + ' ' + _('Tax ID'),
_('Invoice sender:') + ' ' + _('VAT ID'),
@@ -291,6 +292,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
i.invoice_from,
i.invoice_from_zipcode,
i.invoice_from_city,
i.invoice_from_state,
i.invoice_from_country,
i.invoice_from_tax_id,
i.invoice_from_vat_id,
+1 -1
View File
@@ -149,7 +149,7 @@ class ItemDataExporter(ListExporter):
row += [
_("Yes") if i.active and v.active else "",
", ".join([str(sn.label) for sn in sales_channels]),
v.default_price or i.default_price,
v.default_price if v.default_price is not None else i.default_price,
_("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission else "",
+2 -2
View File
@@ -610,7 +610,7 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Attendee name') + ': ' + str(label))
headers += [
_('Attendee email'),
_('Company'),
_('Attendee company'),
_('Address'),
_('ZIP code'),
_('City'),
@@ -650,7 +650,7 @@ class OrderListExporter(MultiSheetListExporter):
options[q.pk].append(o)
headers.append(str(q.question))
headers += [
_('Company'),
_('Invoice address company'),
_('Invoice address name'),
]
if name_scheme and len(name_scheme['fields']) > 1:
+29 -9
View File
@@ -214,21 +214,38 @@ class PasswordRecoverForm(forms.Form):
error_messages = {
'pw_mismatch': _("Please enter the same password twice"),
}
email = forms.EmailField(
max_length=255,
disabled=True,
label=_("Your email address"),
widget=forms.EmailInput(
attrs={'autocomplete': 'username'},
),
)
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput,
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password',
}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput,
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password',
}),
max_length=4096,
)
def __init__(self, user_id=None, *args, **kwargs):
self.user_id = user_id
super().__init__(*args, **kwargs)
initial = kwargs.pop('initial', {})
try:
self.user = User.objects.get(id=user_id)
initial['email'] = self.user.email
except User.DoesNotExist:
self.user = None
super().__init__(*args, initial=initial, **kwargs)
def clean(self):
password1 = self.cleaned_data.get('password', '')
@@ -243,11 +260,7 @@ class PasswordRecoverForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
try:
user = User.objects.get(id=self.user_id)
except User.DoesNotExist:
user = None
if validate_password(password1, user=user) is not None:
if validate_password(password1, user=self.user) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1
@@ -307,3 +320,10 @@ class ReauthForm(forms.Form):
self.error_messages['inactive'],
code='inactive',
)
class ConfirmationCodeForm(forms.Form):
code = forms.IntegerField(
label=_('Confirmation code'),
widget=forms.NumberInput(attrs={'class': 'confirmation-code-input', 'inputmode': 'numeric', 'type': 'text'}),
)
+28 -10
View File
@@ -66,8 +66,10 @@ from geoip2.errors import AddressNotFoundError
from phonenumber_field.formfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber
from phonenumber_field.widgets import PhoneNumberPrefixWidget
from phonenumbers import NumberParseException, national_significant_number
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
from phonenumbers import (
COUNTRY_CODE_TO_REGION_CODE, REGION_CODE_FOR_NON_GEO_ENTITY,
NumberParseException, national_significant_number,
)
from PIL import ImageOps
from pretix.base.forms.widgets import (
@@ -83,7 +85,7 @@ from pretix.base.invoicing.transmission import (
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 (
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
)
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
@@ -305,7 +307,9 @@ class WrappedPhonePrefixSelect(Select):
choices = [("", "---------")]
if initial:
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
continue
if initial in values:
self.initial = "+%d" % prefix
break
@@ -437,7 +441,9 @@ def guess_phone_prefix_from_request(request, event):
def get_phone_prefix(country):
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if country == REGION_CODE_FOR_NON_GEO_ENTITY:
return None
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if country in values:
return prefix
return None
@@ -1165,13 +1171,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['vat_id'].help_text = '<br/>'.join([
str(_('Optional, but depending on the country you reside in we might need to charge you '
'additional taxes if you do not enter it.')),
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
])
else:
self.fields['vat_id'].help_text = '<br/>'.join([
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
'depending on your and the sellers country of residence.')),
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
])
transmission_type_choices = [
@@ -1358,13 +1362,24 @@ class BaseInvoiceAddressForm(forms.ModelForm):
"transmission method.")}
)
vat_id_applicable = (
'vat_id' in self.fields and
data.get('is_business') and
ask_for_vat_id(data.get('country'))
)
vat_id_required = vat_id_applicable and str(data.get('country')) in self.event.settings.invoice_address_vatid_required_countries
if vat_id_required and not data.get('vat_id'):
raise ValidationError({
"vat_id": _("This field is required.")
})
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'):
pass # Skip re-validation if it is validated
elif self.validate_vat_id and vat_id_applicable:
try:
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
self.instance.vat_id_validated = True
self.instance.vat_id = normalized_id
self.instance.vat_id = data['vat_id'] = normalized_id
except VATIDFinalError as e:
if self.all_optional:
self.instance.vat_id_validated = False
@@ -1372,6 +1387,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
else:
raise ValidationError({"vat_id": e.message})
except VATIDTemporaryError as e:
# We couldn't check it online, but we can still normalize it
normalized_id = normalize_vat_id(data.get('vat_id'), str(data.get('country')))
self.instance.vat_id = data['vat_id'] = normalized_id
self.instance.vat_id_validated = False
if self.request and self.vat_warning:
messages.warning(self.request, e.message)
+81 -67
View File
@@ -39,37 +39,16 @@ from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.db.models import Q
from django.urls.base import reverse
from django.utils.translation import gettext_lazy as _
from pytz import common_timezones
from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map
class UserSettingsForm(forms.ModelForm):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
'pw_current': _("Please enter your current password if you want to change your email address "
"or password."),
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
old_pw = forms.CharField(max_length=255,
required=False,
label=_("Your current password"),
widget=forms.PasswordInput())
new_pw = forms.CharField(max_length=255,
required=False,
label=_("New password"),
widget=forms.PasswordInput())
new_pw_repeat = forms.CharField(max_length=255,
required=False,
label=_("Repeat new password"),
widget=forms.PasswordInput())
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
@@ -93,16 +72,63 @@ class UserSettingsForm(forms.ModelForm):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['email'].required = True
if self.user.auth_backend != 'native':
del self.fields['old_pw']
del self.fields['new_pw']
del self.fields['new_pw_repeat']
self.fields['email'].disabled = True
self.fields['email'].disabled = True
self.fields['email'].help_text = format_map('<a href="{link}"><span class="fa fa-edit"></span> {text}</a>', {
'text': _("Change email address"),
'link': reverse('control:user.settings.email.change')
})
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
class UserPasswordChangeForm(forms.Form):
error_messages = {
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
email = forms.EmailField(max_length=255,
disabled=True,
label=_("Your email address"),
widget=forms.EmailInput(
attrs={'autocomplete': 'username'},
))
old_pw = forms.CharField(max_length=255,
required=True,
label=_("Your current password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'current-password'},
))
new_pw = forms.CharField(max_length=255,
required=True,
label=_("New password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'new-password'},
))
new_pw_repeat = forms.CharField(max_length=255,
required=True,
label=_("Repeat new password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'new-password'},
))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
initial = kwargs.pop('initial', {})
initial['email'] = self.user.email
super().__init__(*args, initial=initial, **kwargs)
def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw')
if old_pw and settings.HAS_REDIS:
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
@@ -113,7 +139,7 @@ class UserSettingsForm(forms.ModelForm):
code='rate_limit',
)
if old_pw and not check_password(old_pw, self.user.password):
if not check_password(old_pw, self.user.password):
raise forms.ValidationError(
self.error_messages['pw_current_wrong'],
code='pw_current_wrong',
@@ -121,59 +147,47 @@ class UserSettingsForm(forms.ModelForm):
return old_pw
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
return email
def clean_new_pw(self):
password1 = self.cleaned_data.get('new_pw', '')
if password1 and validate_password(password1, user=self.user) is not None:
if validate_password(password1, user=self.user) is not None:
raise forms.ValidationError(
_(password_validators_help_texts()),
code='pw_invalid'
)
if self.user.check_password(password1):
raise forms.ValidationError(
self.error_messages['pw_equal'],
code='pw_equal',
)
return password1
def clean_new_pw_repeat(self):
password1 = self.cleaned_data.get('new_pw')
password2 = self.cleaned_data.get('new_pw_repeat')
if password1 and password1 != password2:
if password1 != password2:
raise forms.ValidationError(
self.error_messages['pw_mismatch'],
code='pw_mismatch'
)
def clean(self):
password1 = self.cleaned_data.get('new_pw')
email = self.cleaned_data.get('email')
old_pw = self.cleaned_data.get('old_pw')
if (password1 or email != self.user.email) and not old_pw:
class UserEmailChangeForm(forms.Form):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
}
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
new_email = forms.EmailField(label=_('New email address'))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
def clean_new_email(self):
email = self.cleaned_data['new_email']
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
raise forms.ValidationError(
self.error_messages['pw_current'],
code='pw_current'
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
if password1 and password1 == old_pw:
raise forms.ValidationError(
self.error_messages['pw_equal'],
code='pw_equal'
)
if password1:
self.instance.set_password(password1)
return self.cleaned_data
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
return email
+50 -12
View File
@@ -34,14 +34,13 @@
from contextlib import contextmanager
from asgiref.local import Local
from babel import localedata
from django.conf import settings
from django.utils import translation
from django.utils.formats import date_format, number_format
from django.utils.translation import gettext
from pretix.base.templatetags.money import money_filter
from i18nfield.fields import ( # noqa
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
)
@@ -51,6 +50,9 @@ from i18nfield.strings import LazyI18nString # noqa
from i18nfield.utils import I18nJSONEncoder # noqa
_active_region = Local()
class LazyDate:
def __init__(self, value):
self.value = value
@@ -86,6 +88,8 @@ class LazyCurrencyNumber:
return self.__str__()
def __str__(self):
from pretix.base.templatetags.money import money_filter
return money_filter(self.value, self.currency)
@@ -105,14 +109,41 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
def get_babel_locale():
babel_locale = 'en'
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
if translation.get_language():
if localedata.exists(translation.get_language()):
babel_locale = translation.get_language()
elif localedata.exists(translation.get_language()[:2]):
babel_locale = translation.get_language()[:2]
return babel_locale
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
# Also, this returns best-effort region information for number formatting etc
current_language = translation.get_language()
current_region = getattr(_active_region, "value", None)
# Babel only accepts locales that exist on the system. We try combinations in the following order:
# language-languageversion-region
# language-region
# language-languageversion
# language
# fallback to system default
# fallback to english
try_locales = []
if current_language:
if "-" in current_language:
lng_parts = current_language.split("-")
if current_region:
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}")
try_locales.append(f"{lng_parts[0]}_{current_region.upper()}")
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}")
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}")
try_locales.append(f"{lng_parts[0]}")
else:
if current_region:
try_locales.append(f"{current_language}_{current_region.upper()}")
try_locales.append(f"{current_language}")
try_locales.append(settings.LANGUAGE_CODE)
for locale in try_locales:
if localedata.exists(locale):
return locale
return "en"
def get_language_without_region(lng=None):
@@ -132,6 +163,10 @@ def get_language_without_region(lng=None):
return lng
def set_region(region):
_active_region.value = region
@contextmanager
def language(lng, region=None):
"""
@@ -143,15 +178,18 @@ def language(lng, region=None):
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
attribute will be ignored.
"""
_lng = translation.get_language()
lng_before = translation.get_language()
region_before = getattr(_active_region, "value", None)
lng = lng or settings.LANGUAGE_CODE
if '-' not in lng and region:
lng += '-' + region.lower()
translation.activate(lng)
_active_region.value = region
try:
yield
finally:
translation.activate(_lng)
translation.activate(lng_before)
_active_region.value = region_before
class LazyLocaleException(Exception):
+103 -25
View File
@@ -23,6 +23,7 @@ import datetime
import logging
import math
import re
import textwrap
import unicodedata
from collections import defaultdict
from decimal import Decimal
@@ -31,7 +32,6 @@ from itertools import groupby
from typing import Tuple
import bleach
import vat_moss.exchange_rates
from bidi import get_display
from django.contrib.staticfiles import finders
from django.db.models import Sum
@@ -46,7 +46,6 @@ from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
@@ -59,7 +58,8 @@ from pretix.base.services.currencies import SOURCE_NAMES
from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import (
FontFallbackParagraph, ThumbnailingImageReader, reshaper,
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new,
reshaper,
)
from pretix.presale.style import get_fonts
@@ -234,25 +234,25 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
"""
Register fonts with reportlab. By default, this registers the OpenSans font family
"""
pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')))
pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')))
pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')))
pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf')))
register_ttf_font_if_new('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))
register_ttf_font_if_new('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))
register_ttf_font_if_new('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))
register_ttf_font_if_new('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf'))
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
italic='OpenSansIt', boldItalic='OpenSansBI')
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
register_ttf_font_if_new(family, finders.find(styles['regular']['truetype']))
if family == self.event.settings.invoice_renderer_font:
self.font_regular = family
if 'bold' in styles:
self.font_bold = family + ' B'
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype']))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype']))
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
def _normalize(self, text):
# reportlab does not support unicode combination characters
@@ -752,11 +752,59 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return dt.astimezone(tz).date()
total = Decimal('0.00')
if has_taxes:
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
colwidths = [a * doc.width for a in (.65, .20, .15)]
for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
all_lines,
key=_group_key,
is_addon=lambda l: l.description.startswith(" +"),
):
# split description into multiple Paragraphs so each fits in a table cell on a single page
# otherwise PDF-build fails
description_p_list = []
# normalize linebreaks to newlines instead of HTML so we can safely substring
description = description.replace('<br>', '<br />').replace('<br />\n', '\n').replace('<br />', '\n')
# start first line with different settings than the rest of the description
curr_description = description.split("\n", maxsplit=1)[0]
cellpadding = 6 # default cellpadding is only set on right side of column
max_width = colwidths[0] - cellpadding
max_height = self.stylesheet['Normal'].leading * 5
p_style = self.stylesheet['Normal']
for __ in range(1000):
p = FontFallbackParagraph(
self._clean_text(curr_description, tags=['br']),
p_style
)
h = p.wrap(max_width, doc.height)[1]
if h <= max_height:
description_p_list.append(p)
if curr_description == description:
break
description = description[len(curr_description):].lstrip()
curr_description = description.split("\n", maxsplit=1)[0]
# use different settings for all except first line
max_width = sum(colwidths[0:3 if has_taxes else 2]) - cellpadding
max_height = self.stylesheet['Fineprint'].leading * 8
p_style = self.stylesheet['Fineprint']
continue
if not description_p_list:
# first "manual" line is larger than 5 "real" lines => only allow one line and set rest in Fineprint
max_height = self.stylesheet['Normal'].leading
if h > max_height * 1.1:
# quickly bring the text-length down to a managable length to then stepwise reduce
wrap_to = math.ceil(len(curr_description) * max_height * 1.1 / h)
else:
# trim to 95% length, but at most 10 chars to not have strangely short lines in the middle of a paragraph
wrap_to = max(len(curr_description) - 10, math.ceil(len(curr_description) * 0.95))
curr_description = textwrap.wrap(curr_description, wrap_to, replace_whitespace=False, drop_whitespace=False)[0]
# Try to be clever and figure out when organizers would want to show the period. This heuristic is
# not perfect and the only "fully correct" way would be to include the period on every line always,
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice
@@ -810,7 +858,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
# Group together at the end of the invoice
request_show_service_date = period_line
elif period_line:
description += "\n" + period_line
description_p_list.append(FontFallbackParagraph(
period_line,
self.stylesheet['Fineprint']
))
lines = list(lines)
if has_taxes:
@@ -819,13 +870,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
description_p_list.append(FontFallbackParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
tdata.append((
FontFallbackParagraph(
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
description_p_list.pop(0),
str(len(lines)),
localize(tax_rate) + " %",
FontFallbackParagraph(
@@ -837,23 +888,52 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.stylesheet['NormalRight']
),
))
for p in description_p_list:
tdata.append((p, "", "", "", ""))
tstyledata.append((
'SPAN',
(0, len(tdata) - 1),
(2, len(tdata) - 1),
))
else:
if len(lines) > 1:
single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
description_p_list.append(FontFallbackParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
tdata.append((
FontFallbackParagraph(
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
description_p_list.pop(0),
str(len(lines)),
FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
))
for p in description_p_list:
tdata.append((p, "", ""))
tstyledata.append((
'SPAN',
(0, len(tdata) - 1),
(1, len(tdata) - 1),
))
tstyledata += [
(
'BOTTOMPADDING',
(0, len(tdata) - len(description_p_list)),
(-1, len(tdata) - 2),
0
),
(
'TOPPADDING',
(0, len(tdata) - len(description_p_list)),
(-1, len(tdata) - 1),
0
),
]
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
total += gross_value * len(lines)
@@ -863,13 +943,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.65, .20, .15)]
if not self.invoice.is_cancellation:
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
@@ -980,7 +1058,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def fmt(val):
try:
return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display)
return money_filter(val, self.invoice.foreign_currency_display)
except ValueError:
return localize(val) + ' ' + self.invoice.foreign_currency_display
+35 -3
View File
@@ -19,8 +19,11 @@
# 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 base64
import hashlib
import re
import dns.resolver
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _, pgettext
@@ -61,7 +64,7 @@ class PeppolIdValidator:
"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}",
"0208": "[01][0-9]{9}",
"0209": ".*",
"0210": "[A-Z0-9]+",
"0211": "IT[0-9]{11}",
@@ -70,6 +73,9 @@ class PeppolIdValidator:
"0205": "[A-Z0-9]+",
"0221": "T[0-9]{13}",
"0230": ".*",
"0244": "[0-9]{13}",
"0245": "[0-9]{10}",
"0246": "DE[0-9]{9}(-[0-9]{5})?(\\.[0-9A-Z]{1,8})?",
"9901": ".*",
"9902": "[1-9][0-9]{7}",
"9904": "DK[0-9]{8}",
@@ -117,12 +123,14 @@ class PeppolIdValidator:
"9951": ".*",
"9952": ".*",
"9953": ".*",
"9954": ".*",
"9956": "0[0-9]{9}",
"9957": ".*",
"9959": ".*",
}
def __init__(self, validate_online=False):
self.validate_online = validate_online
def __call__(self, value):
if ":" not in value:
raise ValidationError(_("A Peppol participant ID always starts with a prefix, followed by a colon (:)."))
@@ -136,6 +144,28 @@ class PeppolIdValidator:
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})
if self.validate_online:
base_hostnames = ['edelivery.tech.ec.europa.eu', 'acc.edelivery.tech.ec.europa.eu']
smp_id = base64.b32encode(hashlib.sha256(value.lower().encode()).digest()).decode().rstrip("=")
for base_hostname in base_hostnames:
smp_domain = f'{smp_id}.iso6523-actorid-upis.{base_hostname}'
resolver = dns.resolver.Resolver()
try:
answers = resolver.resolve(smp_domain, 'NAPTR', lifetime=1.0)
if answers:
return value
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# ID not registered, do not set found=True
pass
except Exception: # noqa
# Error likely on our end or infrastructure is down, allow user to proceed
return value
raise ValidationError(
_("The Peppol participant ID is not registered on the Peppol network."),
)
return value
@@ -155,7 +185,9 @@ class PeppolTransmissionType(TransmissionType):
"transmission_peppol_participant_id": forms.CharField(
label=_("Peppol participant ID"),
validators=[
PeppolIdValidator(),
PeppolIdValidator(
validate_online=True,
),
]
),
}
+5 -1
View File
@@ -35,7 +35,7 @@ from django.utils.translation.trans_real import (
parse_accept_lang_header,
)
from pretix.base.i18n import get_language_without_region
from pretix.base.i18n import get_language_without_region, set_region
from pretix.base.settings import global_settings_object
from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain,
@@ -92,10 +92,14 @@ class LocaleMiddleware(MiddlewareMixin):
)
if '-' not in language and settings_holder.settings.region:
language += '-' + settings_holder.settings.region
if settings_holder.settings.region:
set_region(settings_holder.settings.region)
else:
gs = global_settings_object(request)
if '-' not in language and gs.settings.region:
language += '-' + gs.settings.region
if gs.settings.region:
set_region(gs.settings.region)
translation.activate(language)
request.LANGUAGE_CODE = get_language_without_region()
@@ -0,0 +1,81 @@
# Generated by Django 4.2.17 on 2025-04-20 13:58
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0292_giftcard_customer"),
]
operations = [
migrations.AddField(
model_name="cartposition",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="cartposition",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="cartposition",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderfee",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderfee",
name="value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderposition",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderposition",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="transaction",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="transaction",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="order",
name="tax_rounding_mode",
field=models.CharField(default="line", max_length=100),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.2.19 on 2025-08-11 10:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0293_cartposition_price_includes_rounding_correction_and_more'),
]
operations = [
migrations.CreateModel(
name='ItemProgramTime',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('start', models.DateTimeField()),
('end', models.DateTimeField()),
('item',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='program_times',
to='pretixbase.item')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.23 on 2025-09-04 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0294_item_program_time"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_verified",
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.24 on 2025-11-10 16:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0295_user_is_verified"),
]
operations = [
migrations.AddField(
model_name="invoice",
name="invoice_from_state",
field=models.CharField(max_length=190, null=True),
),
]
+14
View File
@@ -47,6 +47,19 @@ class DataImportError(LazyLocaleException):
super().__init__(msg)
def rename_duplicates(values):
used = set()
had_duplicates = False
for i, value in enumerate(values):
c = 0
while values[i] in used:
c += 1
values[i] = f'{value}__{c}'
had_duplicates = True
used.add(values[i])
return had_duplicates
def parse_csv(file, length=None, mode="strict", charset=None):
file.seek(0)
data = file.read(length)
@@ -70,6 +83,7 @@ def parse_csv(file, length=None, mode="strict", charset=None):
return None
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
reader._had_duplicates = rename_duplicates(reader.fieldnames)
return reader
+3 -2
View File
@@ -36,8 +36,9 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SubEventItem, SubEventItemVariation, itempicture_upload_to,
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
)
from .log import LogEntry
from .media import ReusableMedium
+79 -1
View File
@@ -35,6 +35,7 @@
import binascii
import json
import operator
import secrets
from datetime import timedelta
from functools import reduce
@@ -44,6 +45,7 @@ from django.contrib.auth.models import (
)
from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import BadRequest, PermissionDenied
from django.db import IntegrityError, models, transaction
from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac
@@ -51,7 +53,6 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_otp.models import Device
from django_scopes import scopes_disabled
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri
@@ -239,9 +240,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
MAX_CONFIRMATION_CODE_ATTEMPTS = 10
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('Email'), max_length=190)
is_verified = models.BooleanField(default=False, verbose_name=_('Verified email address'))
fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name'))
is_active = models.BooleanField(default=True,
@@ -353,6 +356,77 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
except SendMailException:
pass # Already logged
def send_confirmation_code(self, session, reason, email=None, state=None):
"""
Sends a confirmation code via email to the user. The code is only valid for the action specified by `reason`.
The email is either sent to the email address currently on file for the user, or to the one given in the optional `email` parameter.
A `state` value can be provided which is bound to this confirmation code, and returned on successfully checking the code.
:param session: the user's request session
:param reason: the action which should be confirmed using this confirmation code (currently, only `email_change` is allowed)
:param email: optional, the email address to send the confirmation code to
:param state: optional
"""
from pretix.base.services.mail import mail
with language(self.locale):
if reason == 'email_change':
msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
old_email=self.email, new_email=email,
))
elif reason == 'email_verify':
msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
email=self.email,
))
else:
raise Exception('Invalid confirmation code reason')
code = "%07d" % secrets.SystemRandom().randint(0, 9999999)
session['user_confirmation_code:' + reason] = {
'code': code,
'state': state,
'attempts': 0,
}
mail(
email or self.email,
_('pretix confirmation code'),
'pretixcontrol/email/confirmation_code.txt',
{
'user': self,
'reason': msg,
'code': code,
},
event=None,
user=self,
locale=self.locale
)
def check_confirmation_code(self, session, reason, code):
"""
Checks a confirmation code entered by the user against the valid code stored in the session.
If the code is correct, an optional state bound to the code is returned.
If the code is incorrect, PermissionDenied is raised. If the code could not be validated, either because no
code for the given reason is stored, or the number of input attempts is exceeded, BadRequest is raised.
:param session: the user's request session
:param reason: the action which should be confirmed using this confirmation code
:param code: the code entered by the user
:return: optional state bound to this code using the state parameter of send_confirmation_code, None otherwise
"""
stored = session.get('user_confirmation_code:' + reason)
if not stored:
raise BadRequest
if stored['attempts'] > User.MAX_CONFIRMATION_CODE_ATTEMPTS:
raise BadRequest
if int(stored['code']) == int(code):
del session['user_confirmation_code:' + reason]
return stored['state']
else:
stored['attempts'] += 1
session['user_confirmation_code:' + reason] = stored
raise PermissionDenied
def send_password_reset(self):
from pretix.base.services.mail import mail
@@ -633,6 +707,8 @@ class U2FDevice(Device):
@property
def webauthndevice(self):
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
d = json.loads(self.json_data)
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
@@ -662,6 +738,8 @@ class WebAuthnDevice(Device):
@property
def webauthndevice(self):
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
@property
+40 -2
View File
@@ -31,6 +31,7 @@ from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from pretix.helpers.celery import get_task_priority
from pretix.helpers.json import CustomJSONEncoder
@@ -58,6 +59,37 @@ class CachedFile(models.Model):
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
session_key = models.TextField(null=True, blank=True) # only allow download in this session
def session_key_for_request(self, request, salt=None):
from ...api.models import OAuthAccessToken, OAuthApplication
from .devices import Device
from .organizer import TeamAPIToken
if hasattr(request, "auth") and isinstance(request.auth, OAuthAccessToken):
k = f'app:{request.auth.application.pk}'
elif hasattr(request, "auth") and isinstance(request.auth, OAuthApplication):
k = f'app:{request.auth.pk}'
elif hasattr(request, "auth") and isinstance(request.auth, TeamAPIToken):
k = f'token:{request.auth.pk}'
elif hasattr(request, "auth") and isinstance(request.auth, Device):
k = f'device:{request.auth.pk}'
elif request.session.session_key:
k = request.session.session_key
else:
raise ValueError("No auth method found to bind to")
if salt:
k = f"{k}!{salt}"
return k
def allowed_for_session(self, request, salt=None):
return (
not self.session_key or
self.session_key_for_request(request, salt) == self.session_key
)
def bind_to_session(self, request, salt=None):
self.session_key = self.session_key_for_request(request, salt)
@receiver(post_delete, sender=CachedFile)
def cached_file_delete(sender, instance, **kwargs):
@@ -131,9 +163,15 @@ class LoggingMixin:
logentry.save()
if logentry.notification_type:
notify.apply_async(args=(logentry.pk,))
notify.apply_async(
args=(logentry.pk,),
priority=get_task_priority("notifications", logentry.organizer_id),
)
if logentry.webhook_type:
notify_webhooks.apply_async(args=(logentry.pk,))
notify_webhooks.apply_async(
args=(logentry.pk,),
priority=get_task_priority("notifications", logentry.organizer_id),
)
return logentry
+41 -5
View File
@@ -37,7 +37,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
PositionInfo = namedtuple('PositionInfo',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
'voucher_discount'])
@@ -279,6 +279,42 @@ class Discount(LoggedModel):
for idx in condition_idx_group:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
def _addon_idx(self, positions, idx):
"""
If we have the following cart:
- Main product
- 10x Addon product 5
- Main product
- 10x Addon product 5
And we have a discount rule that grants "every 10th product is free", people tend to expect
- Main product
- 9x Addon product 5
- 1x Addon product free
- Main product
- 9x Addon product 5
- 1x Addon product free
And get confused if they get
- Main product
- 8x Addon product 5
- 2x Addon product free
- Main product
- 10x Addon product 5
Even if the result is the same. Therefore, we sort positions in the cart not only by price, but also by their
relative index within their addon group. This is only a heuristic and there are *still* scenarios where the more
unexpected version happens, e.g. if prices are different. We need to accept this as long as discounts work on
cart level and not on addon-group level, but this simple sorting reduces the number of support issues by making
the weird case less likely.
"""
if not positions[idx].addon_to:
return 0
return len([1 for i, p in positions.items() if i < idx and p.addon_to == positions[idx].addon_to])
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
if len(condition_idx_group) < self.condition_min_count:
return
@@ -288,8 +324,8 @@ class Discount(LoggedModel):
if self.benefit_only_apply_to_cheapest_n_matches:
# sort by line_price
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
@@ -434,7 +470,7 @@ class Discount(LoggedModel):
for idx, p in positions.items():
subevent_to_idx[p.subevent_id].append(idx)
for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx].line_price_gross)
v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents
@@ -458,7 +494,7 @@ class Discount(LoggedModel):
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0]
else:
+7 -1
View File
@@ -847,7 +847,7 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
ItemVariationMetaValue, Question, Quota,
ItemProgramTime, ItemVariationMetaValue, Question, Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -990,6 +990,12 @@ class Event(EventMixin, LoggedModel):
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
ia.save(force_insert=True)
if not self.has_subevents and not other.has_subevents:
for ipt in ItemProgramTime.objects.filter(item__event=other).prefetch_related('item'):
ipt.pk = None
ipt.item = item_map[ipt.item.pk]
ipt.save(force_insert=True)
quota_map = {}
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
quota_map[q.pk] = q
+28 -2
View File
@@ -142,6 +142,7 @@ class Invoice(models.Model):
invoice_from_name = models.CharField(max_length=190, null=True)
invoice_from_zipcode = models.CharField(max_length=190, null=True)
invoice_from_city = models.CharField(max_length=190, null=True)
invoice_from_state = models.CharField(max_length=190, null=True)
invoice_from_country = FastCountryField(null=True)
invoice_from_tax_id = models.CharField(max_length=190, null=True)
invoice_from_vat_id = models.CharField(max_length=190, null=True)
@@ -218,10 +219,23 @@ class Invoice(models.Model):
taxidrow = "ABN: %s" % self.invoice_from_tax_id
else:
taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id
state_name = ""
if self.invoice_from_state:
state_name = self.invoice_from_state
if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
try:
state_name = pycountry.subdivisions.get(
code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
).name
except:
pass
parts = [
self.invoice_from_name,
self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
((self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or "") + " " + (state_name or "")).strip(),
self.invoice_from_country.name if self.invoice_from_country else "",
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
taxidrow,
@@ -230,10 +244,22 @@ class Invoice(models.Model):
@property
def address_invoice_from(self):
state_name = ""
if self.invoice_from_state:
state_name = self.invoice_from_state
if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
try:
state_name = pycountry.subdivisions.get(
code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
).name
except:
pass
parts = [
self.invoice_from_name,
self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
" ".join(s for s in [self.invoice_from_zipcode, self.invoice_from_city, state_name] if s),
self.invoice_from_country.name if self.invoice_from_country else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
+27 -2
View File
@@ -505,8 +505,7 @@ class Item(LoggedModel):
verbose_name=_("Free price input"),
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
"additional donations for your event. This is currently not supported for products that are "
"bought as an add-on to other products.")
"additional donations for your event.")
)
free_price_suggestion = models.DecimalField(
verbose_name=_("Suggested price"),
@@ -2294,3 +2293,29 @@ class ItemVariationMetaValue(LoggedModel):
class Meta:
unique_together = ('variation', 'property')
class ItemProgramTime(models.Model):
"""
This model can be used to add a program time to an item.
:param item: The item the program time applies to
:type item: Item
:param start: The date and time this program time starts
:type start: datetime
:param end: The date and time this program time ends
:type end: datetime
"""
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
start = models.DateTimeField(verbose_name=_("Start"))
end = models.DateTimeField(verbose_name=_("End"))
def clean(self):
if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
raise ValidationError(_("You cannot use program times on an event series."))
self.clean_start_end(start=self.start, end=self.end)
super().clean()
def clean_start_end(self, start: datetime = None, end: datetime = None):
if start and end and start > end:
raise ValidationError(_("The program end must not be before the program start."))
+19 -3
View File
@@ -35,11 +35,14 @@
import json
import logging
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import connections, models
from django.utils.functional import cached_property
from pretix.helpers.celery import get_task_priority
class VisibleOnlyManager(models.Manager):
def get_queryset(self):
@@ -138,8 +141,9 @@ class LogEntry(models.Model):
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
if log_entry_type:
sender = self.event if self.event else self.organizer
link_info = log_entry_type.get_object_link_info(self)
if is_app_active(self.event, meta['plugin']):
if is_app_active(sender, meta['plugin']):
return make_link(link_info, log_entry_type.object_link_wrapper)
else:
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
@@ -186,7 +190,19 @@ class LogEntry(models.Model):
to_notify = [o.id for o in objects if o.notification_type]
if to_notify:
notify.apply_async(args=(to_notify,))
organizer_ids = set(o.organizer_id for o in objects if o.notification_type)
notify.apply_async(
args=(to_notify,),
priority=settings.PRIORITY_CELERY_HIGHEST_FUNC(
get_task_priority("notifications", oid) for oid in organizer_ids
),
)
to_wh = [o.id for o in objects if o.webhook_type]
if to_wh:
notify_webhooks.apply_async(args=(to_wh,))
organizer_ids = set(o.organizer_id for o in objects if o.webhook_type)
notify_webhooks.apply_async(
args=(to_wh,),
priority=settings.PRIORITY_CELERY_HIGHEST_FUNC(
get_task_priority("notifications", oid) for oid in organizer_ids
),
)
+117 -19
View File
@@ -81,7 +81,7 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import Customer, User
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.settings import PERSON_NAME_SCHEMES, ROUNDING_MODES
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
from pretix.base.timemachine import time_machine_now
@@ -324,6 +324,11 @@ class Order(LockModel, LoggedModel):
# Invoice needs to be re-issued when the order is paid again
default=False,
)
tax_rounding_mode = models.CharField(
max_length=100,
choices=ROUNDING_MODES,
default="line",
)
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
@@ -1259,7 +1264,8 @@ class Order(LockModel, LoggedModel):
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
create = []
for k in keys:
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k
(positionid, itemid, variationid, subeventid, price, price_includes_rounding_correction, taxrate,
taxruleid, taxvalue, taxvalue_includes_rounding_correction, feetype, internaltype, taxcode) = k
d = target_transaction_count[k] - current_transaction_count[k]
if d:
create.append(Transaction(
@@ -1272,9 +1278,11 @@ class Order(LockModel, LoggedModel):
variation_id=variationid,
subevent_id=subeventid,
price=price,
price_includes_rounding_correction=price_includes_rounding_correction,
tax_rate=taxrate,
tax_rule_id=taxruleid,
tax_value=taxvalue,
tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction,
tax_code=taxcode,
fee_type=feetype,
internal_type=internaltype,
@@ -1449,7 +1457,22 @@ class QuestionAnswer(models.Model):
super().delete(**kwargs)
class AbstractPosition(models.Model):
class RoundingCorrectionMixin:
@property
def gross_price_before_rounding(self):
return self.price - self.price_includes_rounding_correction
@property
def tax_value_before_rounding(self):
return self.tax_value - self.tax_value_includes_rounding_correction
@property
def net_price_before_rounding(self):
return self.gross_price_before_rounding - self.tax_value_before_rounding
class AbstractPosition(RoundingCorrectionMixin, models.Model):
"""
A position can either be one line of an order or an item placed in a cart.
@@ -1499,6 +1522,9 @@ class AbstractPosition(models.Model):
decimal_places=2, max_digits=13,
verbose_name=_("Price")
)
price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
attendee_name_cached = models.CharField(
max_length=255,
verbose_name=_("Attendee name"),
@@ -2272,7 +2298,7 @@ class ActivePositionManager(ScopedManager(organizer='order__event__organizer')._
return super().get_queryset().filter(canceled=False)
class OrderFee(models.Model):
class OrderFee(RoundingCorrectionMixin, models.Model):
"""
An OrderFee object represents a fee that is added to the order total independently of
the actual positions. This might for example be a payment or a shipping fee.
@@ -2322,6 +2348,9 @@ class OrderFee(models.Model):
decimal_places=2, max_digits=13,
verbose_name=_("Value")
)
value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
@@ -2350,6 +2379,9 @@ class OrderFee(models.Model):
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer')
@@ -2398,17 +2430,23 @@ class OrderFee(models.Model):
self.fee_type, self.value
)
def _calculate_tax(self, tax_rule=None, invoice_address=None):
def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None):
if tax_rule:
self.tax_rule = tax_rule
try:
ia = invoice_address or self.order.invoice_address
except InvoiceAddress.DoesNotExist:
if invoice_address:
ia = invoice_address
elif hasattr(self, "order"):
try:
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
else:
ia = None
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default":
self.tax_rule = self.order.event.cached_default_tax_rule
event = event or self.order.event
if not self.tax_rule and self.fee_type == "payment" and event.settings.tax_rule_payment == "default":
self.tax_rule = event.cached_default_tax_rule
if self.tax_rule:
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
@@ -2443,6 +2481,24 @@ class OrderFee(models.Model):
self.order.touch()
super().delete(**kwargs)
# For historical reasons, OrderFee has "value", but OrderPosition has "price". These properties
# help using them the same way.
@property
def price(self):
return self.value
@price.setter
def price(self, value):
self.value = value
@property
def price_includes_rounding_correction(self):
return self.value_includes_rounding_correction
@price_includes_rounding_correction.setter
def price_includes_rounding_correction(self, value):
self.value_includes_rounding_correction = value
class OrderPosition(AbstractPosition):
"""
@@ -2522,6 +2578,9 @@ class OrderPosition(AbstractPosition):
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00"),
)
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
@@ -2694,7 +2753,14 @@ class OrderPosition(AbstractPosition):
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
else:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
op.tax_value = cartpos.tax_value
op.tax_value_includes_rounding_correction = cartpos.tax_value_includes_rounding_correction
op.tax_rate = cartpos.tax_rate
op.tax_code = cartpos.tax_code
op.tax_rule = cartpos.item.tax_rule
# todo: is removing this safe? op._calculate_tax()
if cartpos.voucher:
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
@@ -3027,6 +3093,9 @@ class Transaction(models.Model):
decimal_places=2, max_digits=13,
verbose_name=_("Price")
)
price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
@@ -3044,6 +3113,9 @@ class Transaction(models.Model):
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
fee_type = models.CharField(
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
)
@@ -3073,14 +3145,19 @@ class Transaction(models.Model):
@staticmethod
def key(obj):
if isinstance(obj, Transaction):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
obj.tax_value, obj.tax_value_includes_rounding_correction, obj.fee_type,
obj.internal_type, obj.tax_code)
elif isinstance(obj, OrderPosition):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code)
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
obj.tax_value, obj.tax_value_includes_rounding_correction, None,
None, obj.tax_code)
elif isinstance(obj, OrderFee):
return (None, None, None, None, obj.value, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
return (None, None, None, None, obj.value, obj.value_includes_rounding_correction,
obj.tax_rate, obj.tax_rule_id, obj.tax_value, obj.tax_value_includes_rounding_correction,
obj.fee_type, obj.internal_type, obj.tax_code)
raise ValueError('invalid state') # noqa
@property
@@ -3091,6 +3168,14 @@ class Transaction(models.Model):
def full_tax_value(self):
return self.tax_value * self.count
@property
def full_price_includes_rounding_correction(self):
return self.price_includes_rounding_correction * self.count
@property
def full_tax_value_includes_rounding_correction(self):
return self.tax_value_includes_rounding_correction * self.count
class CartPosition(AbstractPosition):
"""
@@ -3131,6 +3216,13 @@ class CartPosition(AbstractPosition):
max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate')
)
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
listed_price = models.DecimalField(
decimal_places=2, max_digits=13, null=True,
)
@@ -3171,9 +3263,15 @@ class CartPosition(AbstractPosition):
@property
def tax_value(self):
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
price = self.gross_price_before_rounding
net = round_decimal(price - (price * (1 - 100 / (100 + self.tax_rate))),
self.event.currency)
return self.price - net
return self.gross_price_before_rounding - net + self.tax_value_includes_rounding_correction
@tax_value.setter
def tax_value(self, value):
# ignore, tax value is always computed on the fly
pass
@cached_property
def sort_key(self):
+4 -3
View File
@@ -22,7 +22,6 @@
import json
from collections import namedtuple
import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db import models
@@ -38,6 +37,8 @@ from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
@deconstructible
class SeatingPlanLayoutValidator:
def __call__(self, value):
import jsonschema
if not isinstance(value, dict):
try:
val = json.loads(value)
@@ -280,13 +281,13 @@ class Seat(models.Model):
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
sales_channel='web',
ignore_distancing=False, distance_ignore_cart_id=None):
ignore_distancing=False, distance_ignore_cart_id=None, always_allow_blocked=False):
from .orders import Order
from .organizer import SalesChannel
if isinstance(sales_channel, SalesChannel):
sales_channel = sales_channel.identifier
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
if not always_allow_blocked and self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
return False
opqs = self.orderposition_set.filter(
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],
+2 -1
View File
@@ -23,7 +23,6 @@ import json
from decimal import Decimal
from typing import Optional
import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -298,6 +297,8 @@ def cc_to_vat_prefix(country_code):
@deconstructible
class CustomRulesValidator:
def __call__(self, value):
import jsonschema
if not isinstance(value, dict):
try:
val = json.loads(value)
+2 -2
View File
@@ -623,7 +623,7 @@ class Voucher(LoggedModel):
return max(1, self.min_usages - self.redeemed)
@classmethod
def annotate_budget_used_orders(cls, qs):
def annotate_budget_used(cls, qs):
opq = OrderPosition.objects.filter(
voucher_id=OuterRef('pk'),
voucher_budget_use__isnull=False,
@@ -632,7 +632,7 @@ class Voucher(LoggedModel):
Order.STATUS_PENDING
]
).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s')
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00')))
return qs.annotate(budget_used=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00')))
def budget_used(self):
ops = OrderPosition.objects.filter(
+21 -17
View File
@@ -35,6 +35,7 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import User, Voucher
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.helpers import OF_SELF
from ...helpers.format import format_map
from ...helpers.names import build_name
@@ -185,44 +186,47 @@ class WaitingListEntry(LoggedModel):
if not free_seats:
raise WaitingListException(_('No seat with this product is currently available.'))
if self.voucher:
raise WaitingListException(_('A voucher has already been sent to this person.'))
if '@' not in self.email:
raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
with transaction.atomic():
e = self.email
if self.name:
e += ' / ' + self.name
locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
if locked_wle.voucher:
raise WaitingListException(_('A voucher has already been sent to this person.'))
e = locked_wle.email
if locked_wle.name:
e += ' / ' + locked_wle.name
v = Voucher.objects.create(
event=self.event,
event=locked_wle.event,
max_usages=1,
valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
item=self.item,
variation=self.variation,
valid_until=now() + timedelta(hours=locked_wle.event.settings.waiting_list_hours),
item=locked_wle.item,
variation=locked_wle.variation,
tag='waiting-list',
comment=_('Automatically created from waiting list entry for {email}').format(
email=e
),
block_quota=True,
subevent=self.subevent,
subevent=locked_wle.subevent,
)
v.log_action('pretix.voucher.added', {
'item': self.item.pk,
'variation': self.variation.pk if self.variation else None,
'item': locked_wle.item.pk,
'variation': locked_wle.variation.pk if locked_wle.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,
'subevent': locked_wle.subevent.pk if locked_wle.subevent else None,
'source': 'waitinglist',
}, user=user, auth=auth)
v.log_action('pretix.voucher.added.waitinglist', {
'email': self.email,
'waitinglistentry': self.pk,
'email': locked_wle.email,
'waitinglistentry': locked_wle.pk,
}, user=user, auth=auth)
self.voucher = v
self.save()
locked_wle.voucher = v
locked_wle.save()
self.refresh_from_db()
with language(self.locale, self.event.settings.region):
self.send_mail(
+10 -6
View File
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.views import get_cart, get_cart_total
from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__)
@@ -1149,12 +1149,16 @@ class FreeOrderProvider(BasePaymentProvider):
from .services.cart import get_fees
cart = get_cart(request)
total = get_cart_total(request)
try:
total += sum([f.value for f in get_fees(self.event, request, total, None, None, cart)])
fees = get_fees(event=request.event, request=request,
invoice_address=None,
payments=None, positions=cart)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
pass
fees = []
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
return total == 0
def order_change_allowed(self, order: Order) -> bool:
@@ -1373,7 +1377,7 @@ class GiftCardPayment(BasePaymentProvider):
execute_payment_needs_user = False
verbose_name = _("Gift card")
payment_form_class = GiftCardPaymentForm
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html'
payment_form_template_name = 'pretixpresale/giftcard/checkout.html'
@cached_property
def customer_gift_cards(self):
@@ -1500,7 +1504,7 @@ class GiftCardPayment(BasePaymentProvider):
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({
return get_template('pretixpresale/giftcard/checkout_confirm.html').render({
'info_data': info_data,
})
+30 -12
View File
@@ -47,7 +47,6 @@ from collections import OrderedDict, defaultdict
from functools import partial
from io import BytesIO
import jsonschema
import pypdf
import pypdf.generic
import reportlab.rl_config
@@ -72,9 +71,7 @@ from reportlab.lib.colors import Color
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import getAscentDescent
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
@@ -84,7 +81,10 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
from pretix.helpers.daterange import datetimerange
from pretix.helpers.reportlab import (
ThumbnailingImageReader, register_ttf_font_if_new, reshaper,
)
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
@@ -490,6 +490,12 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT"
) if op.valid_until else ""
}),
("program_times", {
"label": _("Program times: date and time"),
"editor_sample": _(
"2017-05-31 10:00 12:00\n2017-05-31 14:00 16:00\n2017-05-31 14:00 2017-06-01 14:00"),
"evaluate": lambda op, order, ev: get_program_times(op, ev)
}),
("medium_identifier", {
"label": _("Reusable Medium ID"),
"editor_sample": "ABC1234DEF4567",
@@ -734,6 +740,16 @@ def get_seat(op: OrderPosition):
return None
def get_program_times(op: OrderPosition, ev: Event):
return '\n'.join([
datetimerange(
pt.start.astimezone(ev.timezone),
pt.end.astimezone(ev.timezone),
as_html=False
) for pt in op.item.program_times.all()
])
def generate_compressed_addon_list(op, order, event):
itemcount = defaultdict(int)
addons = [p for p in (
@@ -778,19 +794,19 @@ class Renderer:
def _register_fonts(cls, event: Event = None):
if hasattr(cls, '_fonts_registered'):
return
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
register_ttf_font_if_new('Open Sans', finders.find('fonts/OpenSans-Regular.ttf'))
register_ttf_font_if_new('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf'))
register_ttf_font_if_new('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf'))
register_ttf_font_if_new('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf'))
for family, styles in get_fonts(event, pdf_support_required=True).items():
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
register_ttf_font_if_new(family, finders.find(styles['regular']['truetype']))
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype']))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype']))
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
cls._fonts_registered = True
@@ -1294,6 +1310,8 @@ def _correct_page_media_box(page: pypdf.PageObject):
@deconstructible
class PdfLayoutValidator:
def __call__(self, value):
import jsonschema
if not isinstance(value, dict):
try:
val = json.loads(value)
+1 -1
View File
@@ -350,7 +350,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
ocm.add_fee(f)
if dry_run:
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff), Decimal("0.00"))
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff_guesstimate), Decimal("0.00"))
else:
ocm.commit()
refund_amount = payment_refund_sum - o.total
+258 -112
View File
@@ -66,8 +66,8 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, lock_objects
from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, get_price,
is_included_for_free,
apply_discounts, apply_rounding, get_line_price, get_listed_price,
get_price, is_included_for_free,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask
@@ -97,6 +97,10 @@ class CartError(Exception):
super().__init__(msg)
class CartPositionError(CartError):
pass
error_messages = {
'busy': gettext_lazy(
'We were not able to process your request completely as the '
@@ -106,6 +110,9 @@ error_messages = {
'unknown_position': gettext_lazy('Unknown cart position.'),
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
'positions_removed': gettext_lazy(
'Some products can no longer be purchased and have been removed from your cart for the following reason: %s'
),
'unavailable': gettext_lazy(
'Some of the products you selected are no longer available. '
'Please see below for details.'
@@ -258,6 +265,138 @@ def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_
return vouchers_ok, _voucher_depend_on_cart
def _check_position_constraints(
event: Event, item: Item, variation: ItemVariation, voucher: Voucher, subevent: SubEvent,
seat: Seat, sales_channel: SalesChannel, already_in_cart: bool, cart_is_expired: bool, real_now_dt: datetime,
item_requires_seat: bool, is_addon: bool, is_bundled: bool,
):
"""
Checks if a cart position with the given constraints can still be sold. This checks configuration and time-based
constraints of item, subevent, and voucher.
It does NOT
- check if quota/voucher/seat are still available
- check prices
- check memberships
- perform any checks that go beyond the single line (like item.max_per_order)
"""
time_machine_now_dt = time_machine_now(real_now_dt)
# Item or variation disabled
# Item disabled or unavailable by time
if not item.is_available(time_machine_now_dt) or (variation and not variation.is_available(time_machine_now_dt)):
raise CartPositionError(error_messages['unavailable'])
# Invalid media policy for online sale
if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[item.media_type]
if not mt.medium_created_by_server:
raise CartPositionError(error_messages['media_usage_not_implemented'])
elif item.media_policy == Item.MEDIA_POLICY_REUSE:
raise CartPositionError(error_messages['media_usage_not_implemented'])
# Item removed from sales channel
if not item.all_sales_channels:
if sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
raise CartPositionError(error_messages['unavailable'])
# Variation removed from sales channel
if variation and not variation.all_sales_channels:
if sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
raise CartPositionError(error_messages['unavailable'])
# Item disabled or unavailable by time in subevent
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(time_machine_now_dt):
raise CartPositionError(error_messages['not_for_sale'])
# Variation disabled or unavailable by time in subevent
if subevent and variation and variation.pk in subevent.var_overrides and \
not subevent.var_overrides[variation.pk].is_available(time_machine_now_dt):
raise CartPositionError(error_messages['not_for_sale'])
# Item requires a variation (should never happen)
if item.has_variations and not variation:
raise CartPositionError(error_messages['not_for_sale'])
# Variation belongs to wrong item (should never happen)
if variation and variation.item_id != item.pk:
raise CartPositionError(error_messages['not_for_sale'])
# Voucher does not apply to product
if voucher and not voucher.applies_to(item, variation):
raise CartPositionError(error_messages['voucher_invalid_item'])
# Voucher does not apply to seat
if voucher and voucher.seat and voucher.seat != seat:
raise CartPositionError(error_messages['voucher_invalid_seat'])
# Voucher does not apply to subevent
if voucher and voucher.subevent_id and voucher.subevent_id != subevent.pk:
raise CartPositionError(error_messages['voucher_invalid_subevent'])
# Voucher expired
if voucher and voucher.valid_until and voucher.valid_until < time_machine_now_dt:
raise CartPositionError(error_messages['voucher_expired'])
# Subevent has been disabled
if subevent and not subevent.active:
raise CartPositionError(error_messages['inactive_subevent'])
# Subevent sale not started
if subevent and subevent.effective_presale_start and time_machine_now_dt < subevent.effective_presale_start:
raise CartPositionError(error_messages['not_started'])
# Subevent sale has ended
if subevent and subevent.presale_has_ended:
raise CartPositionError(error_messages['ended'])
# Payment for subevent no longer possible
if subevent:
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(subevent).date(),
time(hour=23, minute=59, second=59)
), event.timezone)
if term_last < time_machine_now_dt:
raise CartPositionError(error_messages['payment_ended'])
# Seat required but no seat given
if item_requires_seat and not seat:
raise CartPositionError(error_messages['seat_invalid'])
# Seat given but no seat required
if seat and not item_requires_seat:
raise CartPositionError(error_messages['seat_forbidden'])
# Item requires to be add-on but is top-level position
if item.category and item.category.is_addon and not is_addon:
raise CartPositionError(error_messages['addon_only'])
# Item requires bundling but is top-level position
if item.require_bundling and not is_bundled:
raise CartPositionError(error_messages['bundled_only'])
# Seat for wrong product
if seat and seat.product != item:
raise CartPositionError(error_messages['seat_invalid'])
# Seat blocked
if seat and seat.blocked and sales_channel.identifier not in event.settings.seating_allow_blocked_seats_for_channel:
raise CartPositionError(error_messages['seat_invalid'])
# Item requires voucher but no voucher given
if item.require_voucher and voucher is None and not is_bundled:
raise CartPositionError(error_messages['voucher_required'])
# Item or variation is hidden without voucher but no voucher is given
if (
(item.hide_without_voucher or (variation and variation.hide_without_voucher)) and
(voucher is None or not voucher.show_hidden_items) and
not is_bundled
):
raise CartPositionError(error_messages['voucher_required'])
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
@@ -294,6 +433,7 @@ class CartManager:
self._widget_data = widget_data or {}
self._sales_channel = sales_channel
self.num_extended_positions = 0
self.price_change_for_extended = False
if reservation_time:
self._reservation_time = reservation_time
@@ -421,14 +561,14 @@ class CartManager:
if cartsize > limit:
raise CartError(error_messages['max_items'] % limit)
def _check_item_constraints(self, op, current_ops=[]):
def _check_item_constraints(self, op):
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
if not (
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
):
if op.item.require_voucher and op.voucher is None:
if getattr(op, 'voucher_ignored', False):
if getattr(op, 'voucher_ignored', False): # todo??
raise CartError(error_messages['voucher_redeemed'])
raise CartError(error_messages['voucher_required'])
@@ -440,88 +580,39 @@ class CartManager:
raise CartError(error_messages['voucher_redeemed'])
raise CartError(error_messages['voucher_required'])
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
raise CartError(error_messages['unavailable'])
if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[op.item.media_type]
if not mt.medium_created_by_server:
raise CartError(error_messages['media_usage_not_implemented'])
elif op.item.media_policy == Item.MEDIA_POLICY_REUSE:
raise CartError(error_messages['media_usage_not_implemented'])
if not op.item.all_sales_channels:
if self._sales_channel.identifier not in (s.identifier for s in op.item.limit_sales_channels.all()):
raise CartError(error_messages['unavailable'])
if op.variation and not op.variation.all_sales_channels:
if self._sales_channel.identifier not in (s.identifier for s in op.variation.limit_sales_channels.all()):
raise CartError(error_messages['unavailable'])
if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
raise CartError(error_messages['not_for_sale'])
if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and \
not op.subevent.var_overrides[op.variation.pk].is_available():
raise CartError(error_messages['not_for_sale'])
if op.item.has_variations and not op.variation:
raise CartError(error_messages['not_for_sale'])
if op.variation and op.variation.item_id != op.item.pk:
raise CartError(error_messages['not_for_sale'])
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
raise CartError(error_messages['voucher_invalid_item'])
if op.voucher and op.voucher.seat and op.voucher.seat != op.seat:
raise CartError(error_messages['voucher_invalid_seat'])
if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk:
raise CartError(error_messages['voucher_invalid_subevent'])
if op.subevent and not op.subevent.active:
raise CartError(error_messages['inactive_subevent'])
if op.subevent and op.subevent.presale_start and time_machine_now(self.real_now_dt) < op.subevent.presale_start:
raise CartError(error_messages['not_started'])
if op.subevent and op.subevent.presale_has_ended:
raise CartError(error_messages['ended'])
seated = self._is_seated(op.item, op.subevent)
if (
seated and (
not op.seat or (
op.seat.blocked and
self._sales_channel.identifier not in self.event.settings.seating_allow_blocked_seats_for_channel
)
)
):
raise CartError(error_messages['seat_invalid'])
elif op.seat and not seated:
raise CartError(error_messages['seat_forbidden'])
elif op.seat and op.seat.product != op.item:
raise CartError(error_messages['seat_invalid'])
elif op.seat and op.count > 1:
if op.seat and op.count > 1:
raise CartError('Invalid request: A seat can only be bought once.')
if op.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(op.subevent).date(),
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < time_machine_now(self.real_now_dt):
raise CartError(error_messages['payment_ended'])
if isinstance(op, self.AddOperation):
is_addon = op.addon_to
is_bundled = op.addon_to == "FAKE"
else:
is_addon = op.position.addon_to
is_bundled = op.position.is_bundled
if isinstance(op, self.AddOperation):
if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
raise CartError(error_messages['addon_only'])
if op.item.require_bundling and not op.addon_to == 'FAKE':
raise CartError(error_messages['bundled_only'])
try:
_check_position_constraints(
event=self.event,
item=op.item,
variation=op.variation,
voucher=op.voucher,
subevent=op.subevent,
seat=op.seat,
sales_channel=self._sales_channel,
already_in_cart=isinstance(op, self.ExtendOperation),
cart_is_expired=isinstance(op, self.ExtendOperation),
real_now_dt=self.real_now_dt,
item_requires_seat=self._is_seated(op.item, op.subevent),
is_addon=is_addon,
is_bundled=is_bundled,
)
# Quota, seat, and voucher availability is checked for in perform_operations
# Price changes are checked for in extend_expired_positions
except CartPositionError as e:
if e.args[0] == error_messages['voucher_required'] and getattr(op, 'voucher_ignored', False):
# This is the case where someone clicks +1 on a voucher-only item with a fully redeemed voucher:
raise CartPositionError(error_messages['voucher_redeemed'])
raise
def _get_price(self, item: Item, variation: Optional[ItemVariation],
voucher: Optional[Voucher], custom_price: Optional[Decimal],
@@ -541,7 +632,7 @@ class CartManager:
else:
raise e
def extend_expired_positions(self):
def _extend_expired_positions(self):
requires_seat = Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
@@ -604,10 +695,14 @@ class CartManager:
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
price_after_voucher=price_after_voucher,
)
self._check_item_constraints(op)
try:
self._check_item_constraints(op)
except CartPositionError as e:
self._operations.append(self.RemoveOperation(position=cp))
err = error_messages['positions_removed'] % str(e)
if cp.voucher:
self._voucher_use_diff[cp.voucher] += 2
self._voucher_use_diff[cp.voucher] += 1
self._operations.append(op)
return err
@@ -797,7 +892,7 @@ class CartManager:
custom_price_input_is_net=False,
voucher_ignored=False,
)
self._check_item_constraints(bop, operations)
self._check_item_constraints(bop)
bundled.append(bop)
listed_price = get_listed_price(item, variation, subevent)
@@ -836,7 +931,7 @@ class CartManager:
custom_price_input_is_net=self.event.settings.display_net_prices,
voucher_ignored=voucher_ignored,
)
self._check_item_constraints(op, operations)
self._check_item_constraints(op)
operations.append(op)
self._quota_diff.update(quota_diff)
@@ -975,7 +1070,7 @@ class CartManager:
custom_price_input_is_net=self.event.settings.display_net_prices,
voucher_ignored=False,
)
self._check_item_constraints(op, operations)
self._check_item_constraints(op)
operations.append(op)
# Check constraints on the add-on combinations
@@ -1172,7 +1267,9 @@ class CartManager:
op.position.delete()
elif isinstance(op, (self.AddOperation, self.ExtendOperation)):
# Create a CartPosition for as much items as we can
if isinstance(op, self.ExtendOperation) and (op.position.pk in deleted_positions or not op.position.pk):
continue # Already deleted in other operation
# Create a CartPosition for as many items as we can
requested_count = quota_available_count = voucher_available_count = op.count
if op.seat:
@@ -1343,6 +1440,8 @@ class CartManager:
addons.delete()
op.position.delete()
elif available_count == 1:
if op.price_after_voucher != op.position.price_after_voucher:
self.price_change_for_extended = True
op.position.expires = self._expiry
op.position.max_extend = self._max_expiry_extend
op.position.listed_price = op.listed_price
@@ -1361,6 +1460,11 @@ class CartManager:
deleted_positions.add(op.position.pk)
addons.delete()
op.position.delete()
if op.position.is_bundled:
deleted_positions |= {a.pk for a in op.position.addon_to.addons.all()}
deleted_positions.add(op.position.addon_to.pk)
op.position.addon_to.addons.all().delete()
op.position.addon_to.delete()
else:
raise AssertionError("ExtendOperation cannot affect more than one item")
elif isinstance(op, self.VoucherOperation):
@@ -1424,29 +1528,39 @@ class CartManager:
self._sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions
]
)
for cp, (new_price, discount) in zip(positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
diff += new_price - cp.price
if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
diff += new_price - cp.gross_price_before_rounding
cp.price = new_price
cp.price_includes_rounding_correction = Decimal("0.00")
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
return diff
def _remove_parents_if_bundles_are_removed(self):
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
for op in self._operations:
if isinstance(op, self.RemoveOperation):
if op.position.is_bundled and op.position.addon_to_id not in removed_positions:
self._operations.append(self.RemoveOperation(position=op.position.addon_to))
removed_positions.add(op.position.addon_to_id)
def commit(self):
self._check_presale_dates()
self._check_max_cart_size()
err = self._delete_out_of_timeframe()
err = self.extend_expired_positions() or err
err = self._extend_expired_positions() or err
err = err or self._check_min_per_voucher()
self._extend_expiry_of_valid_existing_positions()
self._remove_parents_if_bundles_are_removed()
err = self._perform_operations() or err
self.recompute_final_prices_and_taxes()
@@ -1493,30 +1607,53 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D
add_payment_to_cart_session(cs, provider, min_value, max_value, info_data)
def get_fees(event, request, total, invoice_address, payments, positions):
def get_fees(event, request, _total_ignored_=None, invoice_address=None, payments=None, positions=None):
"""
Return all fees that would be created for the current cart. Also implicitly applies rounding on the order
positions. A recommended usage pattern to compute the total looks like this::
cart = get_cart(request)
fees = get_fees(
event=request.event,
request=request,
invoice_address=cached_invoice_address(request),
payments=None,
positions=cart,
)
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
"""
if payments and not isinstance(payments, list):
raise TypeError("payments must now be a list")
if positions is None:
raise TypeError("Must pass positions, parameter is only optional for backwards-compat reasons")
fees = []
total = sum([c.gross_price_before_rounding for c in positions])
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total, positions=positions, payment_requests=payments):
positions=positions, total=total, payment_requests=payments):
if resp:
fees += resp
total = total + sum(f.value for f in fees)
for fee in fees:
fee._calculate_tax(invoice_address=invoice_address, event=event)
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
if total != 0 and payments:
total_remaining = total
payments_assigned = Decimal("0.00")
for p in payments:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
continue
to_pay = total_remaining
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
@@ -1525,28 +1662,32 @@ def get_fees(event, request, total, invoice_address, payments, positions):
continue
payment_fee = pprov.calculate_fee(to_pay)
total_remaining += payment_fee
to_pay += payment_fee
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
total_remaining -= to_pay
if payment_fee:
if event.settings.tax_rule_payment == "default":
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
else:
payment_fee_tax_rule = TaxRule.zero()
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
fees.append(OrderFee(
pf = OrderFee(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=payment_fee,
tax_rate=payment_fee_tax.rate,
tax_value=payment_fee_tax.tax,
tax_code=payment_fee_tax.code,
tax_rule=payment_fee_tax_rule
))
)
fees.append(pf)
# Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payments_assigned += to_pay
return fees
@@ -1675,7 +1816,12 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.commit()
return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend}
return {
"success": cm.num_extended_positions,
"expiry": cm._expiry,
"max_expiry_extend": cm._max_expiry_extend,
"price_changed": cm.price_change_for_extended,
}
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
+1 -1
View File
@@ -121,7 +121,7 @@ class CrossSellingService:
self.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
bool(cp.addon_to), cp.is_bundled,
cp.addon_to, cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions
],
+13 -4
View File
@@ -93,6 +93,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
@@ -258,9 +259,15 @@ def build_invoice(invoice: Invoice) -> Invoice:
if resp:
desc += "<br/>" + resp
for answ in p.answers.all():
if not answ.question.print_on_invoice:
continue
answers_qs = p.answers.filter(
question__print_on_invoice=True
).select_related(
'question'
).order_by(
'question__position',
'question__id'
)
for answ in answers_qs:
desc += "<br />{}{} {}".format(
answ.question.question,
"" if str(answ.question.question).endswith("?") else ":",
@@ -453,6 +460,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
cancellation.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
cancellation.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
cancellation.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
cancellation.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
cancellation.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
@@ -556,6 +564,7 @@ def build_preview_invoice_pdf(event):
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
@@ -687,7 +696,7 @@ 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),
transmission_date__lte=now() - timedelta(hours=48),
).select_for_update(
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
)
+7 -5
View File
@@ -47,7 +47,6 @@ from urllib.parse import urljoin, urlparse
from zoneinfo import ZoneInfo
import requests
from bs4 import BeautifulSoup
from celery import chain
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
@@ -222,7 +221,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
'invoice_company': ''
})
renderer = ClassicMailRenderer(None, organizer)
content_plain = body_plain = render_mail(template, context)
body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN)
subject = str(subject).format_map(TolerantDict(context))
sender = (
sender or
@@ -316,6 +315,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
with override(timezone):
try:
content_plain = render_mail(template, context, placeholder_mode=None)
if plain_text_only:
body_html = None
elif 'context' in inspect.signature(renderer.render).parameters:
@@ -751,11 +751,11 @@ def mail_send(*args, **kwargs):
mail_send_task.apply_async(args=args, kwargs=kwargs)
def render_mail(template, context):
def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN):
if isinstance(template, LazyI18nString):
body = str(template)
if context:
body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
if context and placeholder_mode:
body = format_map(body, context, mode=placeholder_mode)
else:
tpl = get_template(template)
body = tpl.render(context)
@@ -763,6 +763,8 @@ def render_mail(template, context):
def replace_images_with_cid_paths(body_html):
from bs4 import BeautifulSoup
if body_html:
email = BeautifulSoup(body_html, "lxml")
cid_images = []
+9 -2
View File
@@ -32,6 +32,7 @@ from pretix.base.services.mail import mail_send_task
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import notification
from pretix.celery_app import app
from pretix.helpers.celery import get_task_priority
from pretix.helpers.urls import build_absolute_uri
@@ -88,12 +89,18 @@ def notify(logentry_ids: list):
for um, enabled in notify_specific.items():
user, method = um
if enabled:
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
send_notification.apply_async(
args=(logentry.id, notification_type.action_type, user.pk, method),
priority=get_task_priority("notifications", logentry.organizer_id),
)
for um, enabled in notify_global.items():
user, method = um
if enabled and um not in notify_specific:
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
send_notification.apply_async(
args=(logentry.id, notification_type.action_type, user.pk, method),
priority=get_task_priority("notifications", logentry.organizer_id),
)
notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
+228 -159
View File
@@ -81,7 +81,7 @@ from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.payment import GiftCardPayment, PaymentException
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services import cart, tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
invoice_transmission_separately, order_invoice_transmission_separately,
@@ -95,7 +95,7 @@ from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order,
)
from pretix.base.services.pricing import (
apply_discounts, get_listed_price, get_price,
apply_discounts, apply_rounding, get_listed_price, get_price,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
@@ -130,6 +130,9 @@ class OrderError(Exception):
error_messages = {
'positions_removed': gettext_lazy(
'Some products can no longer be purchased and have been removed from your cart for the following reason: %s'
),
'unavailable': gettext_lazy(
'Some of the products you selected were no longer available. '
'Please see below for details.'
@@ -146,6 +149,10 @@ error_messages = {
'race_condition': gettext_lazy("This order was changed by someone else simultaneously. Please check if your "
"changes are still accurate and try again."),
'empty': gettext_lazy("Your cart is empty."),
'max_items': ngettext_lazy(
"You cannot select more than %s item per order.",
"You cannot select more than %s items per order."
),
'max_items_per_product': ngettext_lazy(
"You cannot select more than %(max)s item of the product %(product)s. We removed the surplus items from your cart.",
"You cannot select more than %(max)s items of the product %(product)s. We removed the surplus items from your cart.",
@@ -178,14 +185,6 @@ error_messages = {
'The voucher code used for one of the items in your cart is not valid for this item. We removed this item from your cart.'
),
'voucher_required': gettext_lazy('You need a valid voucher code to order one of the products.'),
'some_subevent_not_started': gettext_lazy(
'The booking period for one of the events in your cart has not yet started. The '
'affected positions have been removed from your cart.'
),
'some_subevent_ended': gettext_lazy(
'The booking period for one of the events in your cart has ended. The affected '
'positions have been removed from your cart.'
),
'seat_invalid': gettext_lazy('One of the seats in your order was invalid, we removed the position from your cart.'),
'seat_unavailable': gettext_lazy('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
@@ -740,12 +739,37 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
deleted_positions.add(cp.pk)
cp.delete()
sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))
sorted_positions = list(sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk)))
for cp in sorted_positions:
cp._cached_quotas = list(cp.quotas)
for cp in sorted_positions:
try:
cart._check_position_constraints(
event=event,
item=cp.item,
variation=cp.variation,
voucher=cp.voucher,
subevent=cp.subevent,
seat=cp.seat,
sales_channel=sales_channel,
already_in_cart=True,
cart_is_expired=cp.expires < now_dt,
real_now_dt=now_dt,
item_requires_seat=cp.requires_seat,
is_addon=bool(cp.addon_to_id),
is_bundled=bool(cp.addon_to_id) and cp.is_bundled,
)
# Quota, seat, and voucher availability is checked for below
# Prices are checked for below
# Memberships are checked in _create_order
except cart.CartPositionError as e:
err = error_messages['positions_removed'] % str(e)
delete(cp)
# Create locks
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
if any(cp.expires < now() + timedelta(seconds=LOCK_TRUST_WINDOW) for cp in sorted_positions):
# No need to perform any locking if the cart positions still guarantee everything long enough.
full_lock_required = any(
@@ -763,17 +787,19 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
shared_lock_objects=[event]
)
# Check maximum order size
limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE)
if sum(1 for cp in sorted_positions if not cp.addon_to) > limit:
err = err or (error_messages['max_items'] % limit)
# Check availability
for i, cp in enumerate(sorted_positions):
if cp.pk in deleted_positions:
if cp.pk in deleted_positions or not cp.pk:
continue
if not cp.item.is_available() or (cp.variation and not cp.variation.is_available()):
err = err or error_messages['unavailable']
delete(cp)
continue
quotas = cp._cached_quotas
# Product per order limits
products_seen[cp.item] += 1
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
err = error_messages['max_items_per_product'] % {
@@ -783,6 +809,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
delete(cp)
break
# Voucher availability
if cp.voucher:
v_usages[cp.voucher] += 1
if cp.voucher not in v_avail:
@@ -797,48 +824,14 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
delete(cp)
continue
if cp.subevent and cp.subevent.presale_start and time_machine_now_dt < cp.subevent.presale_start:
err = err or error_messages['some_subevent_not_started']
delete(cp)
break
if cp.subevent:
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(cp.subevent).date(),
time(hour=23, minute=59, second=59)
), event.timezone)
if term_last < time_machine_now_dt:
err = err or error_messages['some_subevent_ended']
delete(cp)
break
if cp.subevent and cp.subevent.presale_has_ended:
err = err or error_messages['some_subevent_ended']
delete(cp)
break
if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen:
# Check duplicate seats in order
if cp.seat in seats_seen:
err = err or error_messages['seat_invalid']
delete(cp)
break
if cp.seat:
seats_seen.add(cp.seat)
if cp.item.require_voucher and cp.voucher is None and not cp.is_bundled:
delete(cp)
err = err or error_messages['voucher_required']
break
if (cp.item.hide_without_voucher or (cp.variation and cp.variation.hide_without_voucher)) and (
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
) and not cp.is_bundled:
delete(cp)
err = error_messages['voucher_required']
break
if cp.seat:
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
# time, since we absolutely can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier):
@@ -846,34 +839,13 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
delete(cp)
continue
if cp.expires >= now_dt and not cp.voucher:
# Other checks are not necessary
continue
# Check useful quota configuration
if len(quotas) == 0:
err = err or error_messages['unavailable']
delete(cp)
continue
if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(time_machine_now_dt):
err = err or error_messages['unavailable']
delete(cp)
continue
if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \
not cp.subevent.var_overrides[cp.variation.pk].is_available(time_machine_now_dt):
err = err or error_messages['unavailable']
delete(cp)
continue
if cp.voucher:
if cp.voucher.valid_until and cp.voucher.valid_until < time_machine_now_dt:
err = err or error_messages['voucher_expired']
delete(cp)
continue
quota_ok = True
ignore_all_quotas = cp.expires >= now_dt or (
cp.voucher and (
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
@@ -905,7 +877,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
})
# Check prices
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
old_total = sum(cp.price for cp in sorted_positions)
for i, cp in enumerate(sorted_positions):
if cp.listed_price is None:
@@ -936,21 +908,22 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
delete(cp)
continue
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
discount_results = apply_discounts(
event,
sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions
]
)
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
cp.price = new_price
cp.price_includes_rounding_correction = Decimal("0.00")
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
# After applying discounts, add-on positions might still have a reference to the *old* version of the
# parent position, which can screw up ordering later since the system sees inconsistent data.
@@ -973,10 +946,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
raise OrderError(err)
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False):
def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False):
fees = []
total = sum([c.price for c in positions])
# Pre-rounding, pre-fee total is used for fee calculation
total = sum([c.gross_price_before_rounding for c in positions])
gift_cards = [] # for backwards compatibility
for p in payment_requests:
@@ -987,40 +961,53 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
if resp:
fees += resp
total += sum(f.value for f in fees)
total_remaining = total
for fee in fees:
fee._calculate_tax(invoice_address=address, event=event)
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
# Apply rounding to get final total in case no payment fees will be added
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
payments_assigned = Decimal("0.00")
for p in payment_requests:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
p['payment_amount'] = Decimal('0.00')
continue
to_pay = total_remaining
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payment_fee = p['pprov'].calculate_fee(to_pay)
total_remaining += payment_fee
to_pay += payment_fee
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
total_remaining -= to_pay
p['payment_amount'] = to_pay
if payment_fee:
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=p['pprov'].identifier)
pf._calculate_tax(invoice_address=address, event=event)
fees.append(pf)
p['fee'] = pf
if total_remaining != Decimal('0.00') and not require_approval:
# Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payments_assigned += to_pay
p['payment_amount'] = to_pay
if total != payments_assigned and not require_approval:
raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees
@@ -1029,7 +1016,7 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
customer=None, valid_if_pending=False, api_meta: dict=None):
customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
payments = []
try:
@@ -1038,10 +1025,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
raise OrderError(e.message)
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
# Final calculation of fees, also performs final rounding
try:
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
order = Order(
@@ -1059,6 +1049,7 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
sales_channel=sales_channel,
customer=customer,
valid_if_pending=valid_if_pending,
tax_rounding_mode=tax_rounding_mode or event.settings.tax_rounding,
)
if customer:
order.email_known_to_work = customer.is_verified
@@ -1073,12 +1064,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
for fee in fees:
fee.order = order
try:
fee._calculate_tax()
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
fee.save()
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
@@ -1167,7 +1152,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
shown_total=None, customer=None, api_meta: dict=None):
shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
for p in payment_requests:
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
if not p['pprov']:
@@ -1273,6 +1258,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
customer=customer,
valid_if_pending=valid_if_pending,
api_meta=api_meta,
tax_rounding_mode=tax_rounding_mode,
)
try:
@@ -1644,7 +1630,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled'))
'valid_from', 'valid_until', 'is_bundled', 'result'))
SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1656,15 +1642,28 @@ class OrderChangeManager:
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
class AddPositionResult:
_position: Optional[OrderPosition]
def __init__(self):
self._position = None
@property
def position(self) -> OrderPosition:
if self._position is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._position
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order
self.user = user
self.auth = auth
self.event = order.event
self.split_order = None
self.reissue_invoice = reissue_invoice
self.allow_blocked_seats = allow_blocked_seats
self._committed = False
self._totaldiff = 0
self._totaldiff_guesstimate = 0
self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = []
@@ -1781,7 +1780,7 @@ class OrderChangeManager:
if position.issued_gift_cards.exists():
raise OrderError(self.error_messages['gift_card_change'])
self._totaldiff += price.gross - position.price
self._totaldiff_guesstimate += price.gross - position.gross_price_before_rounding
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
@@ -1826,29 +1825,29 @@ class OrderChangeManager:
else:
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
override_tax_rate=new_rate, override_tax_code=new_code)
self._totaldiff += new_tax.gross - pos.price
self._totaldiff_guesstimate += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._invoice_dirty = True
def cancel_fee(self, fee: OrderFee):
self._totaldiff -= fee.value
self._totaldiff_guesstimate -= fee.value
self._operations.append(self.CancelFeeOperation(fee, -fee.value))
self._invoice_dirty = True
def add_fee(self, fee: OrderFee):
self._totaldiff += fee.value
self._totaldiff_guesstimate += fee.value
self._invoice_dirty = True
self._operations.append(self.AddFeeOperation(fee, fee.value))
def change_fee(self, fee: OrderFee, value: Decimal):
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address,
force_fixed_gross_price=True)
self._totaldiff += value.gross - fee.value
self._totaldiff_guesstimate += value.gross - fee.value
self._invoice_dirty = True
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
def cancel(self, position: OrderPosition):
self._totaldiff -= position.price
self._totaldiff_guesstimate -= position.price
self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position, -position.price))
if position.seat:
@@ -1859,7 +1858,7 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None):
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
if isinstance(seat, str):
if not seat:
seat = None
@@ -1914,12 +1913,15 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff += price.gross
self._totaldiff_guesstimate += price.gross
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled))
valid_from, valid_until, is_bundled, result))
return result
def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -2183,7 +2185,7 @@ class OrderChangeManager:
for seat, diff in self._seatdiff.items():
if diff <= 0:
continue
if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True) or diff > 1:
if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True, always_allow_blocked=self.allow_blocked_seats) or diff > 1:
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
if self.event.has_subevents:
@@ -2210,8 +2212,8 @@ class OrderChangeManager:
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
raise OrderError(self.error_messages['quota'].format(name=quota.name))
def _check_paid_price_change(self):
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
def _check_paid_price_change(self, totaldiff):
if self.order.status == Order.STATUS_PAID and totaldiff > 0:
if self.order.pending_sum > Decimal('0.00'):
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
@@ -2219,7 +2221,7 @@ class OrderChangeManager:
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
)
self.order.save()
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff < 0:
if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval:
self.order.status = Order.STATUS_PAID
self.order.save()
@@ -2246,7 +2248,7 @@ class OrderChangeManager:
user=self.user,
auth=self.auth
)
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff > 0:
if self.open_payment:
try:
self.open_payment.payment_provider.cancel_payment(self.open_payment)
@@ -2266,11 +2268,11 @@ class OrderChangeManager:
auth=self.auth,
)
def _check_paid_to_free(self):
if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"):
def _check_paid_to_free(self, totaldiff):
if self.event.currency == 'XXX' and self.order.total + totaldiff > Decimal("0.00"):
raise OrderError(error_messages['currency_XXX'])
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
if self.order.total == 0 and (totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
if not self.order.fees.exists() and not self.order.positions.exists():
# The order is completely empty now, so we cancel it.
self.order.status = Order.STATUS_CANCELED
@@ -2278,7 +2280,7 @@ class OrderChangeManager:
order_canceled.send(self.order.event, order=self.order)
elif self.order.status != Order.STATUS_CANCELED:
# if the order becomes free, mark it paid using the 'free' provider
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
# this could happen if positions have been made cheaper or removed (totaldiff < 0)
# or positions got split off to a new order (split_order with positive total)
p = self.order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
@@ -2407,10 +2409,15 @@ class OrderChangeManager:
'new_price': op.price.gross
})
position.price = op.price.gross
position.price_includes_rounding_correction = Decimal("0.00")
position.tax_rate = op.price.rate
position.tax_value = op.price.tax
position.tax_value_includes_rounding_correction = Decimal("0.00")
position.tax_code = op.price.code
position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code'])
position.save(update_fields=[
'price', 'price_includes_rounding_correction', 'tax_rate', 'tax_value',
'tax_value_includes_rounding_correction', 'tax_code'
])
elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition):
position = position_cache.setdefault(op.position.pk, op.position)
@@ -2533,6 +2540,7 @@ class OrderChangeManager:
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
})
op.result._position = pos
elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position)
@@ -2677,14 +2685,18 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist:
pass
split_order.total = sum([p.price for p in split_positions if not p.canceled])
fees = []
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = modelcopy(fee)
new_fee.pk = None
new_fee.order = split_order
split_order.total += new_fee.value
new_fee.save()
fees.append(new_fee)
changed_by_rounding = set(apply_rounding(
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
))
split_order.total = sum([p.price for p in split_positions if not p.canceled])
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
pp = self._get_payment_provider()
@@ -2697,9 +2709,27 @@ class OrderChangeManager:
fee._calculate_tax()
if payment_fee != 0:
fee.save()
fees.append(fee)
elif fee.pk:
if fee in fees:
fees.remove(fee)
fee.delete()
split_order.total += fee.value
changed_by_rounding |= set(apply_rounding(
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
))
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
for l in changed_by_rounding:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
@@ -2759,9 +2789,12 @@ class OrderChangeManager:
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return payment_sum - refund_sum
def _recalculate_total_and_payment_fee(self):
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
def _recalculate_rounding_total_and_payment_fee(self):
positions = list(self.order.positions.all())
fees = list(self.order.fees.all())
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
payment_fee = Decimal('0.00')
fee_changed = False
if self.open_payment:
current_fee = Decimal('0.00')
fee = None
@@ -2789,14 +2822,32 @@ class OrderChangeManager:
fee.value = payment_fee
fee._calculate_tax()
fee.save()
fee_changed = True
if not self.open_payment.fee:
self.open_payment.fee = fee
self.open_payment.save(update_fields=['fee'])
elif fee and not fee.canceled:
fee.delete()
fee_changed = True
self.order.total = total + payment_fee
if fee_changed:
fees = list(self.order.fees.all())
changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees])
for l in changed:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
self.order.total = total
self.order.save()
return total
def _check_order_size(self):
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
@@ -2806,23 +2857,6 @@ class OrderChangeManager:
}
)
def _payment_fee_diff(self):
total = self.order.total + self._totaldiff
if self.open_payment:
current_fee = Decimal('0.00')
if self.open_payment and self.open_payment.fee:
current_fee = self.open_payment.fee.value
total -= current_fee
# Do not change payment fees of paid orders
payment_fee = Decimal('0.00')
if self.order.pending_sum - current_fee != 0:
prov = self.open_payment.payment_provider
if prov:
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
self._totaldiff += payment_fee - current_fee
def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
if self.reissue_invoice and self._invoice_dirty:
@@ -2953,6 +2987,13 @@ class OrderChangeManager:
shared_lock_objects=[self.event]
)
def guess_totaldiff(self):
"""
Return the estimated difference of ``order.total`` based on the currently queued operations. This is only
a guess since it does not account for (a) tax rounding or (b) payment fee changes.
"""
return self._totaldiff_guesstimate
def commit(self, check_quotas=True):
if self._committed:
# an order change can only be committed once
@@ -2968,8 +3009,6 @@ class OrderChangeManager:
# so it's dangerous to keep the cache around.
self.order._prefetched_objects_cache = {}
# finally, incorporate difference in payment fees
self._payment_fee_diff()
self._check_order_size()
with transaction.atomic():
@@ -2977,6 +3016,7 @@ class OrderChangeManager:
if locked_instance.last_modified != self.order.last_modified:
raise OrderError(error_messages['race_condition'])
original_total = self.order.total
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
if check_quotas:
self._check_quotas()
@@ -2988,9 +3028,10 @@ class OrderChangeManager:
self._perform_operations()
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
self._recalculate_total_and_payment_fee()
self._check_paid_price_change()
self._check_paid_to_free()
new_total = self._recalculate_rounding_total_and_payment_fee()
totaldiff = new_total - original_total
self._check_paid_price_change(totaldiff)
self._check_paid_to_free(totaldiff)
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
self._reissue_invoice()
self._clear_tickets_cache()
@@ -3209,6 +3250,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
raise Exception('change_payment_provider should only be called in atomic transaction!')
oldtotal = order.total
already_paid = order.payment_refund_sum
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED))
open_fees = list(
@@ -3225,19 +3267,46 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order)
old_fee = fee.value
positions = list(order.positions.all())
fees = list(order.fees.all())
rounding_changed = set(apply_rounding(
order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
))
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
new_fee = payment_provider.calculate_fee(
order.pending_sum - old_fee if amount is None else amount
pending_sum_without_fee if amount is None else amount
)
if new_fee:
fee.value = new_fee
fee.internal_type = payment_provider.identifier
fee._calculate_tax()
if fee in fees:
fees.remove(fee)
# "Update instance in the fees array
fees.append(fee)
fee.save()
else:
if fee in fees:
fees.remove(fee)
if fee.pk:
fee.delete()
fee = None
rounding_changed |= set(apply_rounding(
order.tax_rounding_mode, order.event.currency, [*positions, *fees]
))
for l in rounding_changed:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
open_payment = None
if new_payment:
lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
@@ -3264,7 +3333,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
},
)
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
order.total = sum(c.price for c in positions) + sum(f.value for f in fees)
order.save(update_fields=['total'])
if not new_payment:
+57 -19
View File
@@ -26,7 +26,7 @@ from decimal import Decimal
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.html import escape, mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -123,6 +123,10 @@ class BaseRichTextPlaceholder(BaseTextPlaceholder):
def identifier(self):
return self._identifier
@property
def allowed_in_plain_content(self):
return False
@property
def required_context(self):
return self._args
@@ -194,6 +198,33 @@ class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
return f'{text}: {url}'
class MarkdownTextPlaceholder(BaseRichTextPlaceholder):
def __init__(self, identifier, args, func, sample, inline):
super().__init__(identifier, args)
self._func = func
self._sample = sample
self._snippet = inline
@property
def allowed_in_plain_content(self):
return self._snippet
def render_plain(self, **context):
return self._func(**{k: context[k] for k in self._args})
def render_html(self, **context):
return mark_safe(markdown_compile_email(self.render_plain(**context), snippet=self._snippet))
def render_sample_plain(self, event):
if callable(self._sample):
return self._sample(event)
else:
return self._sample
def render_sample_html(self, event):
return mark_safe(markdown_compile_email(self.render_sample_plain(event), snippet=self._snippet))
class PlaceholderContext(SafeFormatter):
"""
Holds the contextual arguments and corresponding list of available placeholders for formatting
@@ -574,7 +605,7 @@ def base_placeholders(sender, **kwargs):
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
_('Sample Corporation')
),
SimpleFunctionalTextPlaceholder(
MarkdownTextPlaceholder(
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format(
order.full_code,
@@ -604,6 +635,7 @@ def base_placeholders(sender, **kwargs):
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
]
),
inline=False,
),
SimpleFunctionalTextPlaceholder(
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
@@ -618,12 +650,13 @@ def base_placeholders(sender, **kwargs):
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
'68CYU2H6ZTP3WLK5'
),
SimpleFunctionalTextPlaceholder(
MarkdownTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
'68CYU2H6ZTP3WLK5 \n7MB94KKPVEPSMVF2',
inline=False,
),
SimpleFunctionalTextPlaceholder(
MarkdownTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_url_list', ['event', 'voucher_list'],
lambda event, voucher_list: ' \n'.join([
@@ -638,6 +671,7 @@ def base_placeholders(sender, **kwargs):
) + '?voucher=' + c
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
]),
inline=False,
),
SimpleFunctionalTextPlaceholder(
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
@@ -656,13 +690,13 @@ def base_placeholders(sender, **kwargs):
'comment', ['comment'], lambda comment: comment,
_('An individual text with a reason can be inserted here.'),
),
SimpleFunctionalTextPlaceholder(
MarkdownTextPlaceholder(
'payment_info', ['order', 'payments'], _placeholder_payments,
_('The amount has been charged to your card.'),
_('The amount has been charged to your card.'), inline=False,
),
SimpleFunctionalTextPlaceholder(
MarkdownTextPlaceholder(
'payment_info', ['payment_info'], lambda payment_info: payment_info,
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
_('Please transfer money to this bank account: 9999-9999-9999-9999'), inline=False,
),
SimpleFunctionalTextPlaceholder(
'attendee_name', ['position'], lambda position: position.attendee_name,
@@ -719,13 +753,13 @@ def base_placeholders(sender, **kwargs):
))
for k, v in sender.meta_data.items():
ph.append(SimpleFunctionalTextPlaceholder(
ph.append(MarkdownTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
v
v, inline=True,
))
ph.append(SimpleFunctionalTextPlaceholder(
ph.append(MarkdownTextPlaceholder(
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
v
v, inline=True,
))
return ph
@@ -753,7 +787,7 @@ def get_available_placeholders(event, base_parameters, rich=False):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if isinstance(v, BaseRichTextPlaceholder) and not rich:
if isinstance(v, BaseRichTextPlaceholder) and not rich and not v.allowed_in_plain_content:
continue
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
@@ -767,7 +801,11 @@ def get_sample_context(event, context_parameters, rich=True):
sample = v.render_sample(event)
if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString(
sample.plain,
'<{el} class="placeholder" title="{title}">{plain}</{el}>'.format(
el='span',
title=lbl,
plain=escape(sample.plain),
),
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
el='div' if sample.is_block else 'span',
title=lbl,
@@ -775,13 +813,13 @@ def get_sample_context(event, context_parameters, rich=True):
)
)
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
context_dict[k] = '<div class="placeholder" title="{}">{}</div>'.format(
context_dict[k] = mark_safe('<div class="placeholder" title="{}">{}</div>'.format(
lbl,
markdown_compile_email(str(sample))
)
))
else:
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
context_dict[k] = mark_safe('<span class="placeholder" title="{}">{}</span>'.format(
lbl,
escape(sample)
)
))
return context_dict
+133 -9
View File
@@ -23,15 +23,17 @@ import re
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
from typing import List, Optional, Tuple, Union
from itertools import groupby
from typing import List, Literal, Optional, Tuple, Union
from django import forms
from django.conf import settings
from django.db.models import Q
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
SalesChannel, Voucher,
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
)
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent
@@ -136,7 +138,8 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice:
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal,
is_bundled=False) -> TaxedPrice:
if not tax_rule:
tax_rule = TaxRule(
name='',
@@ -152,7 +155,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross',
override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
else:
@@ -164,13 +168,15 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
"""
Applies any dynamic discounts to a cart
:param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
a better result may be given if addons to the same main product have the same distinct value.
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
@@ -192,9 +198,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount)
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
for
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
in enumerate(positions)
if not is_bundled and idx not in new_prices
}, collect_potential_discounts)
@@ -203,3 +209,121 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
new_prices.update(result)
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str,
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
"""
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
and mutates the ``price``, ``price_includes_rounding_correction``, ``tax_value``, and
``tax_value_includes_rounding_correction`` attributes.
When rounding mode is set to ``"line"``, the tax will be computed and rounded individually for every line.
When rounding mode is set to ``"sum_by_net_keep_gross"``, the tax values of the individual lines will be adjusted
such that the per-taxrate/taxcode subtotal is rounded correctly. The gross prices will stay constant.
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``.
:param currency: Currency that will be used to determine rounding precision
:param lines: List of order/cart contents
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
"""
def _key(line):
return (line.tax_rate, line.tax_code or "")
places = settings.CURRENCY_PLACES.get(currency, 2)
minimum_unit = Decimal('1') / 10 ** places
changed = []
if rounding_mode == "sum_by_net":
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
# Compute the net and gross total of the line-based computation method
net_total = sum(l.net_price_before_rounding for l in lines)
gross_total = sum(l.gross_price_before_rounding for l in lines)
# Compute the gross total we need to achieve based on the net total
target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency)
# Add/subtract the smallest possible from both gross prices and tax values (so net values stay the same)
# until the values align
diff = target_gross_total - gross_total
diff_sgn = -1 if diff < 0 else 1
for l in lines:
if diff:
apply_diff = diff_sgn * minimum_unit
l.price = l.gross_price_before_rounding + apply_diff
l.price_includes_rounding_correction = apply_diff
l.tax_value = l.tax_value_before_rounding + apply_diff
l.tax_value_includes_rounding_correction = apply_diff
diff -= apply_diff
changed.append(l)
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
elif rounding_mode == "sum_by_net_keep_gross":
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
# Compute the net and gross total of the line-based computation method
net_total = sum(l.net_price_before_rounding for l in lines)
gross_total = sum(l.gross_price_before_rounding for l in lines)
# Compute the net total that would yield the correct gross total (if possible)
target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency)
# Compute the gross total that would be computed from that net total this will be different than
# gross_total when there is no possible net value for the gross total
# e.g. 99.99 at 19% is impossible since 84.03 + 19% = 100.00 and 84.02 + 19% = 99.98
target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency)
diff_gross = target_gross_total - gross_total
diff_net = target_net_total - net_total
diff_gross_sgn = -1 if diff_gross < 0 else 1
diff_net_sgn = -1 if diff_net < 0 else 1
for l in lines:
if diff_gross:
apply_diff = diff_gross_sgn * minimum_unit
l.price = l.gross_price_before_rounding + apply_diff
l.price_includes_rounding_correction = apply_diff
l.tax_value = l.tax_value_before_rounding + apply_diff
l.tax_value_includes_rounding_correction = apply_diff
changed.append(l)
diff_gross -= apply_diff
elif diff_net:
apply_diff = diff_net_sgn * minimum_unit
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding - apply_diff
l.tax_value_includes_rounding_correction = -apply_diff
changed.append(l)
diff_net -= apply_diff
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
elif rounding_mode == "line":
for l in lines:
if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
else:
raise ValueError("Unknown rounding_mode")
return changed
+186 -13
View File
@@ -27,7 +27,6 @@ from decimal import Decimal
from xml.etree import ElementTree
import requests
import vat_moss.id
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from zeep import Client, Transport
@@ -42,14 +41,142 @@ logger = logging.getLogger(__name__)
error_messages = {
'unavailable': _(
'Your VAT ID could not be checked, as the VAT checking service of '
'your country is currently not available. We will therefore '
'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.'
'your country is currently not available. We will therefore need to '
'charge you the same tax rate as if you did not enter a VAT ID.'
),
'invalid': _('This VAT ID is not valid. Please re-check your input.'),
'country_mismatch': _('Your VAT ID does not match the selected country.'),
}
VAT_ID_PATTERNS = {
# Patterns generated by consulting the following URLs:
#
# - http://en.wikipedia.org/wiki/VAT_identification_number
# - http://ec.europa.eu/taxation_customs/vies/faq.html
# - https://euipo.europa.eu/tunnel-web/secure/webdav/guest/document_library/Documents/COSME/VAT%20numbers%20EU.pdf
# - http://www.skatteetaten.no/en/International-pages/Felles-innhold-benyttes-i-flere-malgrupper/Brochure/Guide-to-value-added-tax-in-Norway/?chapter=7159
'AT': { # Austria
'regex': '^U\\d{8}$',
'country_code': 'AT'
},
'BE': { # Belgium
'regex': '^(1|0?)\\d{9}$',
'country_code': 'BE'
},
'BG': { # Bulgaria
'regex': '^\\d{9,10}$',
'country_code': 'BG'
},
'CH': { # Switzerland
'regex': '^\\dE{9}$',
'country_code': 'CH'
},
'CY': { # Cyprus
'regex': '^\\d{8}[A-Z]$',
'country_code': 'CY'
},
'CZ': { # Czech Republic
'regex': '^\\d{8,10}$',
'country_code': 'CZ'
},
'DE': { # Germany
'regex': '^\\d{9}$',
'country_code': 'DE'
},
'DK': { # Denmark
'regex': '^\\d{8}$',
'country_code': 'DK'
},
'EE': { # Estonia
'regex': '^\\d{9}$',
'country_code': 'EE'
},
'EL': { # Greece
'regex': '^\\d{9}$',
'country_code': 'GR'
},
'ES': { # Spain
'regex': '^[A-Z0-9]\\d{7}[A-Z0-9]$',
'country_code': 'ES'
},
'FI': { # Finland
'regex': '^\\d{8}$',
'country_code': 'FI'
},
'FR': { # France
'regex': '^[A-Z0-9]{2}\\d{9}$',
'country_code': 'FR'
},
'GB': { # United Kingdom
'regex': '^(GD\\d{3}|HA\\d{3}|\\d{9}|\\d{12})$',
'country_code': 'GB'
},
'HR': { # Croatia
'regex': '^\\d{11}$',
'country_code': 'HR'
},
'HU': { # Hungary
'regex': '^\\d{8}$',
'country_code': 'HU'
},
'IE': { # Ireland
'regex': '^(\\d{7}[A-Z]{1,2}|\\d[A-Z+*]\\d{5}[A-Z])$',
'country_code': 'IE'
},
'IT': { # Italy
'regex': '^\\d{11}$',
'country_code': 'IT'
},
'LT': { # Lithuania
'regex': '^(\\d{9}|\\d{12})$',
'country_code': 'LT'
},
'LU': { # Luxembourg
'regex': '^\\d{8}$',
'country_code': 'LU'
},
'LV': { # Latvia
'regex': '^\\d{11}$',
'country_code': 'LV'
},
'MT': { # Malta
'regex': '^\\d{8}$',
'country_code': 'MT'
},
'NL': { # Netherlands
'regex': '^\\d{9}B\\d{2}$',
'country_code': 'NL'
},
'NO': { # Norway
'regex': '^\\d{9}MVA$',
'country_code': 'NO'
},
'PL': { # Poland
'regex': '^\\d{10}$',
'country_code': 'PL'
},
'PT': { # Portugal
'regex': '^\\d{9}$',
'country_code': 'PT'
},
'RO': { # Romania
'regex': '^\\d{2,10}$',
'country_code': 'RO'
},
'SE': { # Sweden
'regex': '^\\d{12}$',
'country_code': 'SE'
},
'SI': { # Slovenia
'regex': '^\\d{8}$',
'country_code': 'SI'
},
'SK': { # Slovakia
'regex': '^\\d{10}$',
'country_code': 'SK'
},
}
class VATIDError(Exception):
def __init__(self, message):
@@ -64,13 +191,57 @@ class VATIDTemporaryError(VATIDError):
pass
def normalize_vat_id(vat_id, country_code):
"""
Accepts a VAT ID and normaizes it, getting rid of spaces, periods, dashes
etc and converting it to upper case.
Original function from https://github.com/wbond/vat_moss-python
Copyright (c) 2015 Will Bond <will@wbond.net>
MIT License
"""
if not vat_id:
return None
if not isinstance(vat_id, str):
raise TypeError('VAT ID is not a string')
if len(vat_id) < 3:
raise ValueError('VAT ID must be at least three character long')
# Normalize the ID for simpler regexes
vat_id = re.sub('\\s+', '', vat_id)
vat_id = vat_id.replace('-', '')
vat_id = vat_id.replace('.', '')
vat_id = vat_id.upper()
# Clean the different shapes a number can take in Switzerland depending on purpse
if country_code == "CH":
vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', ''))
# Fix people using GR prefix for Greece
if vat_id[0:2] == "GR" and country_code == "GR":
vat_id = "EL" + vat_id[2:]
# Check if we already have a valid country prefix. If not, we try to figure out if we can
# add one, since in some countries (e.g. Italy) it's very custom to enter it without the prefix
if vat_id[:2] in VAT_ID_PATTERNS and re.match(VAT_ID_PATTERNS[vat_id[0:2]]['regex'], vat_id[2:]):
# Prefix set and prefix matches pattern, nothing to do
pass
elif re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], vat_id):
# Prefix not set but adding it fixes pattern
vat_id = cc_to_vat_prefix(country_code) + vat_id
else:
# We have no idea what this is
pass
return vat_id
def _validate_vat_id_NO(vat_id, country_code):
# Inspired by vat_moss library
if not vat_id.startswith("NO"):
# prefix is not usually used in Norway, but expected by vat_moss library
vat_id = "NO" + vat_id
try:
vat_id = vat_moss.id.normalize(vat_id)
vat_id = normalize_vat_id(vat_id, country_code)
except ValueError:
raise VATIDFinalError(error_messages['invalid'])
@@ -104,7 +275,7 @@ def _validate_vat_id_NO(vat_id, country_code):
def _validate_vat_id_EU(vat_id, country_code):
# Inspired by vat_moss library
try:
vat_id = vat_moss.id.normalize(vat_id)
vat_id = normalize_vat_id(vat_id, country_code)
except ValueError:
raise VATIDFinalError(error_messages['invalid'])
@@ -112,11 +283,10 @@ def _validate_vat_id_EU(vat_id, country_code):
raise VATIDFinalError(error_messages['invalid'])
number = vat_id[2:]
if vat_id[:2] != cc_to_vat_prefix(country_code):
raise VATIDFinalError(error_messages['country_mismatch'])
if not re.match(vat_moss.id.ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number):
if not re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number):
raise VATIDFinalError(error_messages['invalid'])
# We are relying on the country code of the normalized VAT-ID and not the user/InvoiceAddress-provided
@@ -175,9 +345,12 @@ def _validate_vat_id_EU(vat_id, country_code):
def _validate_vat_id_CH(vat_id, country_code):
if vat_id[:3] != 'CHE':
raise VATIDFinalError(_('Your VAT ID does not match the selected country.'))
raise VATIDFinalError(error_messages['country_mismatch'])
vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', ''))
try:
vat_id = normalize_vat_id(vat_id, country_code)
except ValueError:
raise VATIDFinalError(error_messages['invalid'])
try:
transport = Transport(
cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")),
+5
View File
@@ -113,6 +113,11 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
lock_objects(quotas, shared_lock_objects=[event])
for wle in qs:
# add this event to wle.item as it is not yet cached and is needed in check_quotas
wle.item.event = event
if wle.variation:
wle.variation.item = wle.item
if (wle.item_id, wle.variation_id, wle.subevent_id) in gone:
continue
ev = (wle.subevent or event)
+115 -7
View File
@@ -40,6 +40,7 @@ from datetime import datetime
from decimal import Decimal
from typing import Any
import pycountry
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -77,6 +78,13 @@ from pretix.control.forms import (
)
from pretix.helpers.countries import CachedCountries
ROUNDING_MODES = (
('line', _('Compute taxes for every line individually')),
('sum_by_net', _('Compute taxes based on net total')),
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
# We could also have sum_by_gross, but we're not aware of any use-cases for it
)
def country_choice_kwargs():
allcountries = list(CachedCountries())
@@ -172,6 +180,19 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
)
},
'customer_accounts_require_login_for_order_access': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require login to access order confirmation pages"),
help_text=_("If enabled, users who were logged in at the time of purchase must also log in to access their order information. "
"If a customer account is created while placing an order, the restriction only becomes active after the customer "
"account is activated."),
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
)
},
'customer_accounts_link_by_email': {
'default': 'False',
'type': bool,
@@ -324,7 +345,7 @@ DEFAULTS = {
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
label=_("Show net prices instead of gross prices in the product list"),
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
"paid."),
@@ -465,6 +486,25 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
)
},
'tax_rounding': {
'default': 'line',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'form_kwargs': dict(
label=_("Rounding of taxes"),
widget=forms.RadioSelect,
choices=ROUNDING_MODES,
help_text=_(
"Note that if you transfer your sales data from pretix to an external system for tax reporting, you "
"need to make sure to account for possible rounding differences if your external system rounds "
"differently than pretix."
)
),
'serializer_kwargs': dict(
choices=ROUNDING_MODES,
),
},
'invoice_address_asked': {
'default': 'True',
'type': bool,
@@ -602,13 +642,40 @@ DEFAULTS = {
'form_kwargs': dict(
label=_("Ask for VAT ID"),
help_text=format_lazy(
_("Only works if an invoice address is asked for. VAT ID is never required and only requested from "
"business customers in the following countries: {countries}"),
_("Only works if an invoice address is asked for. VAT ID is only requested from business customers "
"in the following countries: {countries}."),
countries=lazy(lambda *args: ', '.join(sorted(gettext(Country(cc).name) for cc in VAT_ID_COUNTRIES)), str)()
),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
)
},
'invoice_address_vatid_required_countries': {
'default': ['IT', 'GR'],
'type': list,
'form_class': forms.MultipleChoiceField,
'serializer_class': serializers.MultipleChoiceField,
'serializer_kwargs': dict(
choices=lazy(
lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]),
list
)(),
),
'form_kwargs': dict(
label=_("Require VAT ID in"),
choices=lazy(
lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]),
list
)(),
help_text=format_lazy(
_("VAT ID is optional by default, because not all businesses are assigned a VAT ID in all countries. "
"VAT ID will be required for all business addresses in the selected countries."),
),
widget=forms.CheckboxSelectMultiple(attrs={
"class": "scrolling-multiple-choice",
'data-display-dependency': '#id_invoice_address_vatid'
}),
)
},
'invoice_address_explanation_text': {
'default': '',
'type': LazyI18nString,
@@ -663,6 +730,7 @@ DEFAULTS = {
label=_("Minimum length of invoice number after prefix"),
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
max_value=12,
min_value=1,
required=True,
)
},
@@ -698,8 +766,9 @@ DEFAULTS = {
message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format(
allowed='A-Z, a-z, 0-9, -./:#'
), str)()
)
),
],
max_length=155,
)
},
'invoice_numbers_prefix_cancellations': {
@@ -720,8 +789,9 @@ DEFAULTS = {
message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format(
allowed='A-Z, a-z, 0-9, -./:#'
), str)()
)
),
],
max_length=155,
)
},
'invoice_renderer_highlight_order_code': {
@@ -1176,6 +1246,7 @@ DEFAULTS = {
'form_class': forms.CharField,
'serializer_class': serializers.CharField,
'form_kwargs': dict(
max_length=190,
label=_("Company name"),
)
},
@@ -1189,6 +1260,7 @@ DEFAULTS = {
'placeholder': '12345'
}),
label=_("ZIP code"),
max_length=190,
)
},
'invoice_address_from_city': {
@@ -1201,15 +1273,35 @@ DEFAULTS = {
'placeholder': _('Random City')
}),
label=_("City"),
max_length=190,
)
},
'invoice_address_from_state': {
'default': '',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': {
'choices': [('', '')],
},
'form_kwargs': {
"label": pgettext_lazy('address', 'State'),
'choices': [('', '')],
},
},
'invoice_address_from_country': {
'default': '',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': lambda: dict(**country_choice_kwargs()),
'form_kwargs': lambda: dict(label=_('Country'), **country_choice_kwargs()),
'form_kwargs': lambda: dict(
label=_('Country'),
widget=forms.Select(attrs={
'data-trigger-address-info': 'on',
}),
**country_choice_kwargs()
),
},
'invoice_address_from_tax_id': {
'default': '',
@@ -1218,7 +1310,8 @@ DEFAULTS = {
'serializer_class': serializers.CharField,
'form_kwargs': dict(
label=_("Domestic tax ID"),
help_text=_("e.g. tax number in Germany, ABN in Australia, …")
help_text=_("e.g. tax number in Germany, ABN in Australia, …"),
max_length=190,
)
},
'invoice_address_from_vat_id': {
@@ -1228,6 +1321,7 @@ DEFAULTS = {
'serializer_class': serializers.CharField,
'form_kwargs': dict(
label=_("EU VAT ID"),
max_length=190,
)
},
'invoice_introductory_text': {
@@ -3945,6 +4039,20 @@ def validate_event_settings(event, settings_dict):
raise ValidationError({
'invoice_address_company_required': _('You have to require invoice addresses to require for company names.')
})
if settings_dict.get('invoice_address_from_state') and settings_dict.get('invoice_address_from_country'):
cc = str(settings_dict.get('invoice_address_from_country'))
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'invoice_address_from_state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + settings_dict.get('invoice_address_from_state')):
raise ValidationError(
{'invoice_address_from_state': [
'"{}" is not a known subdivision of the country "{}".'.format(
settings_dict.get('invoice_address_from_state'), cc
)
]}
)
payment_term_last = settings_dict.get('payment_term_last')
if payment_term_last and event.presale_end:
+65
View File
@@ -0,0 +1,65 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix 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 datetime import datetime
from django import template
from django.utils.html import format_html
from django.utils.timezone import get_current_timezone
from pretix.base.i18n import LazyExpiresDate
from pretix.helpers.templatetags.date_fast import date_fast
register = template.Library()
@register.simple_tag
def html_time(value: datetime, dt_format: str = "SHORT_DATE_FORMAT", **kwargs):
"""
Building a <time datetime='{html-datetime}'>{human-readable datetime}</time> html string,
where the html-datetime as well as the human-readable datetime can be set
to a value from django's FORMAT_SETTINGS or "format_expires".
If attr_fmt isnt provided, it will be set to isoformat.
Usage example:
{% html_time event_start "SHORT_DATETIME_FORMAT" %}
or
{% html_time event_start "TIME_FORMAT" attr_fmt="H:i" %}
"""
if value in (None, ''):
return ''
value = value.astimezone(get_current_timezone())
attr_fmt = kwargs["attr_fmt"] if kwargs else None
try:
if not attr_fmt:
date_html = value.isoformat()
else:
date_html = date_fast(value, attr_fmt)
if dt_format == "format_expires":
date_human = LazyExpiresDate(value)
else:
date_human = date_fast(value, dt_format)
return format_html("<time datetime='{}'>{}</time>", date_html, date_human)
except AttributeError:
return ''
+6 -8
View File
@@ -26,7 +26,8 @@ from babel.numbers import format_currency
from django import template
from django.conf import settings
from django.template.defaultfilters import floatformat
from django.utils import translation
from pretix.base.i18n import get_babel_locale
register = template.Library()
@@ -59,13 +60,10 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
if hide_currency:
return floatformat(value, f"{places}g")
locale_parts = translation.get_language().split("-", 1)
locale = locale_parts[0]
if len(locale_parts) > 1 and len(locale_parts[1]) == 2:
try:
locale = Locale(locale_parts[0], locale_parts[1].upper())
except UnknownLocaleError:
pass
try:
locale = Locale(get_babel_locale())
except UnknownLocaleError:
locale = "en"
try:
return format_currency(value, arg, locale=locale)
+60 -16
View File
@@ -32,18 +32,20 @@
# 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.
import html
import re
import urllib.parse
import bleach
import markdown
from bleach import DEFAULT_CALLBACKS
from bleach.linkifier import build_email_re, build_url_re
from bleach import DEFAULT_CALLBACKS, html5lib_shim
from bleach.linkifier import build_email_re
from django import template
from django.conf import settings
from django.core import signing
from django.urls import reverse
from django.utils.functional import SimpleLazyObject
from django.utils.html import escape
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.safestring import mark_safe
from markdown import Extension
@@ -52,6 +54,8 @@ from markdown.postprocessors import Postprocessor
from markdown.treeprocessors import UnescapeTreeprocessor
from tlds import tld_set
from pretix.helpers.format import SafeFormatter, format_map
register = template.Library()
@@ -121,6 +125,23 @@ ALLOWED_ATTRIBUTES = {
ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'}
def build_url_re(tlds=tld_set, protocols=html5lib_shim.allowed_protocols):
# Differs from bleach regex by allowing { and } in URL to allow placeholders in URL parameters
return re.compile(
r"""\(* # Match any opening parentheses.
\b(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)? # http://
([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)?
(?:[/?][^\s\|\\\^`<>"]*)?
# /path/zz (excluding "unsafe" chars from RFC 3986,
# except for # and ~, which happen in practice)
""".format(
"|".join(sorted(protocols)), "|".join(sorted(tlds))
),
re.IGNORECASE | re.VERBOSE | re.UNICODE,
)
URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True)))
EMAIL_RE = SimpleLazyObject(lambda: build_email_re(tlds=sorted(tld_set, key=len, reverse=True)))
@@ -321,27 +342,50 @@ class LinkifyAndCleanExtension(Extension):
)
def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes=ALLOWED_ATTRIBUTES):
def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED_ATTRIBUTES, snippet=False, context=None):
if allowed_tags is None:
allowed_tags = ALLOWED_TAGS_SNIPPET if snippet else ALLOWED_TAGS
context_callbacks = []
if context:
# This is a workaround to fix placeholders in URL targets
def context_callback(attrs, new=False):
if (None, "href") in attrs and "{" in attrs[None, "href"]:
# Do not use MODE_RICH_TO_HTML to avoid recursive linkification.
# We want to esacpe the end result, however, we need to unescape the input to prevent & being turned
# to &amp;amp; because the input is already escaped by the markdown parser.
attrs[None, "href"] = escape(format_map(
html.unescape(attrs[None, "href"]),
context=context,
mode=SafeFormatter.MODE_RICH_TO_PLAIN
))
return attrs
context_callbacks.append(context_callback)
linker = bleach.Linker(
url_re=URL_RE,
email_re=EMAIL_RE,
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
callbacks=context_callbacks + DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
parse_email=True
)
exts = [
'markdown.extensions.sane_lists',
'markdown.extensions.tables',
EmailNl2BrExtension(),
LinkifyAndCleanExtension(
linker,
tags=set(allowed_tags),
attributes=allowed_attributes,
protocols=ALLOWED_PROTOCOLS,
strip=snippet,
)
]
if snippet:
exts.append(SnippetExtension())
return markdown.markdown(
source,
extensions=[
'markdown.extensions.sane_lists',
'markdown.extensions.tables',
EmailNl2BrExtension(),
LinkifyAndCleanExtension(
linker,
tags=set(allowed_tags),
attributes=allowed_attributes,
protocols=ALLOWED_PROTOCOLS,
strip=False,
)
]
extensions=exts
)
+3 -1
View File
@@ -93,7 +93,9 @@ def timeline_for_event(event, subevent=None):
description=format_lazy(
'{} ({})',
pgettext_lazy('timeline', 'End of ticket sales'),
pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured') if not ev.presale_end else ""
pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured')
) if not ev.presale_end else (
pgettext_lazy('timeline', 'End of ticket sales')
),
edit_url=ev_edit_url + '#id_presale_end_0'
))
+2 -3
View File
@@ -36,9 +36,8 @@ class DownloadView(TemplateView):
def object(self) -> CachedFile:
try:
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
if o.session_key:
if o.session_key != self.request.session.session_key:
raise Http404()
if not o.allowed_for_session(self.request):
raise Http404()
return o
except (ValueError, ValidationError): # Invalid URLs
raise Http404()
+35 -2
View File
@@ -22,7 +22,7 @@
import pycountry
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import pgettext
from django.utils.translation import gettext, pgettext, pgettext_lazy
from django_countries.fields import Country
from django_scopes import scope
@@ -36,6 +36,28 @@ from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
)
VAT_ID_LABELS = {
# VAT ID is a EU concept and Switzerland has a distinct, but differently-named concept
# Translators: Only translate to French (IDE) and Italien (IDI), otherwise keep the same
"CH": pgettext_lazy("tax_id_swiss", "UID"),
# Awareness around VAT IDs differes by EU country. For example, in Germany the VAT ID is assigned
# separately to each company and only used in cross-country transactions. Therefore, it makes sense
# to call it just "VAT ID" on the form, and people will either know their VAT ID or they don't.
# In contrast, in Italy the EU-compatible VAT ID is not separately assigned, but is just "IT" + the national tax
# number (Partita IVA) and also used on domestic transactions. So someone who never purchased something international
# for their company, might still know the value, if we call it the right way and not just "VAT ID".
# Translators: Translate to only "P.IVA" in Italian, keep second part as-is in other languages
"IT": pgettext_lazy("tax_id_italy", "VAT ID / P.IVA"),
# Translators: Translate to only "ΑΦΜ" in Greek
"GR": pgettext_lazy("tax_id_greece", "VAT ID / TIN"),
# Translators: Translate to only "NIF" in Spanish
"ES": pgettext_lazy("tax_id_spain", "VAT ID / NIF"),
# Translators: Translate to only "NIF" in Portuguese
"PT": pgettext_lazy("tax_id_portugal", "VAT ID / NIF"),
}
def _info(cc):
info = {
@@ -47,7 +69,12 @@ def _info(cc):
'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},
'vat_id': {
'visible': cc in VAT_ID_COUNTRIES,
'required': False,
'label': VAT_ID_LABELS.get(cc, gettext("VAT ID")),
'helptext_visible': True,
},
}
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return {'data': [], **info}
@@ -124,4 +151,10 @@ def address_form(request):
"required": transmission_type.identifier == selected_transmission_type and k in required
}
if is_business and country in event.settings.invoice_address_vatid_required_countries and info["vat_id"]["visible"]:
info["vat_id"]["required"] = True
if info["vat_id"]["required"]:
# The help text explains that it is optional, so we want to hide that if it is required
info["vat_id"]["helptext_visible"] = False
return JsonResponse(info)
+140 -14
View File
@@ -42,11 +42,10 @@ import pycountry
from django import forms
from django.conf import settings
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
from django.core.validators import MaxValueValidator
from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import formset_factory, inlineformset_factory
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.functional import cached_property, lazy
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name
@@ -54,7 +53,7 @@ from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from django_countries.fields import LazyTypedChoiceField
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput,
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
)
from pytz import common_timezones
@@ -67,8 +66,9 @@ from pretix.base.models.tax import TAX_CODE_LISTS
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS, validate_event_settings,
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, DEFAULTS,
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES,
validate_event_settings,
)
from pretix.base.validators import multimail_validate
from pretix.control.forms import (
@@ -207,6 +207,7 @@ class EventWizardBasicsForm(I18nModelForm):
'Sample Conference Center\nHeidelberg, Germany'
)
self.fields['slug'].widget.prefix = build_absolute_uri(self.organizer, 'presale:organizer.index')
self.fields['tax_rate']._required = True # Do not render as optional because it is conditionally required
if self.has_subevents:
del self.fields['presale_start']
del self.fields['presale_end']
@@ -373,6 +374,13 @@ class EventUpdateForm(I18nModelForm):
super().__init__(*args, **kwargs)
if not self.change_slug:
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
if self.instance.orders.exists():
self.fields['currency'].disabled = True
self.fields['currency'].help_text = _(
'The currency cannot be changed because orders already exist.'
)
self.fields['location'].widget.attrs['rows'] = '3'
self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center\nHeidelberg, Germany'
@@ -541,7 +549,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'show_date_to',
'show_times',
'show_items_outside_presale_period',
'display_net_prices',
'hide_prices_from_attendees',
'presale_start_show_date',
'locales',
@@ -799,6 +806,80 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
return value
class DisplayNetPricesBooleanSelect(forms.RadioSelect):
def __init__(self, attrs=None):
choices = (
("false", format_html(
'{} <br><span class="text-muted">{}</span>',
_("Prices including tax"),
_("Recommended if you sell tickets at least partly to consumers.")
)),
("true", format_html(
'{} <br><span class="text-muted">{}</span>',
_("Prices excluding tax"),
_("Recommended only if you sell tickets primarily to business customers.")
)),
)
super().__init__(attrs, choices)
def format_value(self, value):
try:
return {
True: "true",
False: "false",
"true": "true",
"false": "false",
}[value]
except KeyError:
return "unknown"
def value_from_datadict(self, data, files, name):
value = data.get(name)
return {
True: True,
"True": True,
"False": False,
False: False,
"true": True,
"false": False,
}.get(value)
class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
auto_fields = [
'display_net_prices',
'tax_rounding',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["display_net_prices"].label = _("Prices shown to customer")
self.fields["display_net_prices"].widget = DisplayNetPricesBooleanSelect()
help_text = {
"line": _(
"Recommended when e-invoicing is not required. Each product will be sold with the advertised "
"net and gross price. However, in orders of more than one product, the total tax amount "
"can differ from when it would be computed from the order total."
),
"sum_by_net": _(
"Recommended for e-invoicing when you primarily sell to business customers and "
"show prices to customers excluding tax. "
"The gross price of some products may be changed to ensure correct rounding, while the net "
"prices will be kept as configured. This may cause the actual payment amount to differ."
),
"sum_by_net_keep_gross": _(
"Recommended for e-invoicing when you primarily sell to consumers. "
"The gross or net price of some products may be changed automatically to ensure correct "
"rounding of the order total. The system attempts to keep gross prices as configured whenever "
"possible. Gross prices may still change if they are impossible to derive from a rounded net price."
),
}
self.fields["tax_rounding"].choices = (
(k, format_html('{}<br><span class="text-muted">{}</span>', v, help_text.get(k, "")))
for k, v in ROUNDING_MODES
)
class ProviderForm(SettingsForm):
"""
This is a SettingsForm, but if fields are set to required=True, validation
@@ -847,6 +928,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_address_asked',
'invoice_address_required',
'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required',
'invoice_address_beneficiary',
'invoice_address_custom_field',
@@ -872,6 +954,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_address_from',
'invoice_address_from_zipcode',
'invoice_address_from_city',
'invoice_address_from_state',
'invoice_address_from_country',
'invoice_address_from_tax_id',
'invoice_address_from_vat_id',
@@ -918,8 +1001,6 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
self.fields['invoice_generate_sales_channels'].choices = (
(c.identifier, c.label) for c in event.organizer.sales_channels.all()
)
self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15))
pps = [str(pp.verbose_name) for pp in event.get_payment_providers().values() if pp.requires_invoice_immediately]
if pps:
generate_paid_help_text = _('An invoice will be issued before payment if the customer selects one of the following payment methods: {list}').format(
@@ -944,6 +1025,26 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
(a, a) for a in get_fonts(event, pdf_support_required=True).keys()
]
if 'invoice_address_from_country' in self.data:
cc = str(self.data['invoice_address_from_country'])
elif 'invoice_address_from_country' in self.initial:
cc = str(self.initial['invoice_address_from_country'])
else:
cc = self.obj.settings.invoice_address_from_country
c = [('', '---')]
state_label = pgettext_lazy('address', 'State')
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
if cc in COUNTRY_STATE_LABEL:
state_label = COUNTRY_STATE_LABEL[cc]
elif 'invoice_address_from_state' in self.data:
self.data = self.data.copy()
del self.data['invoice_address_from_state']
self.fields['invoice_address_from_state'].choices = c
self.fields['invoice_address_from_state'].label = state_label
def contains_web_channel_validate(val):
if "web" not in val:
@@ -1210,9 +1311,17 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
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."),
widget=I18nTextarea, # no Markdown supported
help_text=lazy(
lambda: str(_(
"This will only be used if the invoice is sent to a different email address or at a different time "
"than the order confirmation."
)) + " " + str(_(
"Formatting is not supported, as some accounting departments process mail automatically and do not "
"handle formatted emails properly."
)),
str
)()
)
mail_subject_download_reminder = I18nFormField(
label=_("Subject sent to order contact address"),
@@ -1380,6 +1489,9 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
'mail_subject_resend_all_links': ['event', 'orders'],
'mail_attach_ical_description': ['event', 'event_or_subevent'],
}
plain_rendering = {
'mail_text_order_invoice',
}
def __init__(self, *args, **kwargs):
self.event = event = kwargs.get('obj')
@@ -1398,7 +1510,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
for k, v in self.base_context.items():
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_'))
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_') and k not in self.plain_rendering)
for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
@@ -1527,7 +1639,10 @@ class TaxRuleLineForm(I18nForm):
rate = forms.DecimalField(
label=_('Deviating tax rate'),
max_digits=10, decimal_places=2,
required=False
required=False,
widget=forms.NumberInput(attrs={
'placeholder': _('Deviating tax rate'),
})
)
invoice_text = I18nFormField(
label=_('Text on invoice'),
@@ -1762,7 +1877,11 @@ class QuickSetupForm(I18nForm):
self.fields['payment_banktransfer_bank_details'].required = False
for f in self.fields.values():
if 'data-required-if' in f.widget.attrs:
del f.widget.attrs['data-required-if']
f.widget.attrs['data-required-if'] += ",#id_payment_banktransfer__enabled"
self.fields['payment_banktransfer_bank_details'].widget.attrs["data-required-if"] = (
"#id_payment_banktransfer_bank_details_type_1,#id_payment_banktransfer__enabled"
)
def clean(self):
cleaned_data = super().clean()
@@ -1851,6 +1970,13 @@ class EventFooterLinkForm(I18nModelForm):
class Meta:
model = EventFooterLink
fields = ('label', 'url')
widgets = {
"url": forms.URLInput(
attrs={
"placeholder": "https://..."
}
)
}
class BaseEventFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet):
+127
View File
@@ -61,6 +61,10 @@ from pretix.base.models import (
SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
)
from pretix.base.signals import register_payment_providers
from pretix.base.timeframes import (
DateFrameField,
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
)
from pretix.control.forms import SplitDateTimeField
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import order_search_filter_q
@@ -1219,6 +1223,129 @@ class OrderPaymentSearchFilterForm(forms.Form):
return qs
class QuestionAnswerFilterForm(forms.Form):
STATUS_VARIANTS = [
("", _("All orders")),
(Order.STATUS_PAID, _("Paid")),
(Order.STATUS_PAID + 'v', _("Paid or confirmed")),
(Order.STATUS_PENDING, _("Pending")),
(Order.STATUS_PENDING + Order.STATUS_PAID, _("Pending or paid")),
("o", _("Pending (overdue)")),
(Order.STATUS_EXPIRED, _("Expired")),
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _("Pending or expired")),
(Order.STATUS_CANCELED, _("Canceled"))
]
status = forms.ChoiceField(
choices=STATUS_VARIANTS,
required=False,
label=_("Order status"),
)
item = forms.ChoiceField(
choices=[],
required=False,
label=_("Products"),
)
subevent = forms.ModelChoiceField(
queryset=SubEvent.objects.none(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates'),
label=pgettext_lazy("subevent", "Date"),
)
date_range = DateFrameField(
required=False,
include_future_frames=True,
label=_('Event date'),
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.initial['status'] = Order.STATUS_PENDING + Order.STATUS_PAID
choices = [('', _('All products'))]
for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all())
if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=str(i))))
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
else:
choices.append((str(i.pk), str(i)))
self.fields['item'].choices = choices
if self.event.has_subevents:
self.fields["subevent"].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
else:
del self.fields['subevent']
def clean(self):
cleaned_data = super().clean()
subevent = cleaned_data.get('subevent')
date_range = cleaned_data.get('date_range')
if subevent is not None and date_range is not None:
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
if (
(d_start and not (d_start <= subevent.date_from)) or
(d_end and not (subevent.date_from < d_end))
):
self.add_error('subevent', pgettext_lazy('subevent', "Date doesn't start in selected date range."))
return cleaned_data
def filter_qs(self, opqs):
fdata = self.cleaned_data
subevent = fdata.get('subevent', None)
date_range = fdata.get('date_range', None)
if subevent is not None:
opqs = opqs.filter(subevent=subevent)
if date_range is not None:
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
opqs = opqs.filter(
subevent__date_from__gte=d_start,
subevent__date_from__lt=d_end
)
s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID)
if s != "":
if s == Order.STATUS_PENDING:
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == Order.STATUS_PENDING + Order.STATUS_PAID:
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == Order.STATUS_PAID + 'v':
opqs = opqs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
elif s == Order.STATUS_PENDING + Order.STATUS_EXPIRED:
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
opqs = opqs.filter(order__status=s)
if s not in (Order.STATUS_CANCELED, ""):
opqs = opqs.filter(canceled=False)
if fdata.get("item", "") != "":
i = fdata.get("item", "")
opqs = opqs.filter(item_id__in=(i,))
return opqs
class SubEventFilterForm(FilterForm):
orders = {
'date_from': 'date_from',
+50 -1
View File
@@ -56,7 +56,8 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
Item, ItemCategory, ItemProgramTime, ItemVariation, Question,
QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
@@ -572,6 +573,8 @@ class ItemCreateForm(I18nModelForm):
for b in self.cleaned_data['copy_from'].bundles.all():
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
count=b.count, designated_price=b.designated_price)
for pt in self.cleaned_data['copy_from'].program_times.all():
instance.program_times.create(start=pt.start, end=pt.end)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
@@ -1321,3 +1324,49 @@ class ItemMetaValueForm(forms.ModelForm):
widgets = {
'value': forms.TextInput()
}
class ItemProgramTimeFormSet(I18nFormSet):
template = "pretixcontrol/item/include_program_times.html"
title = _('Program times')
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
event=self.event
)
self.add_fields(form, None)
return form
class ItemProgramTimeForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix)
class Meta:
model = ItemProgramTime
localized_fields = '__all__'
fields = [
'start',
'end',
]
field_classes = {
'start': forms.SplitDateTimeField,
'end': forms.SplitDateTimeField,
}
widgets = {
'start': SplitDateTimePickerWidget(),
'end': SplitDateTimePickerWidget(),
}
+2 -2
View File
@@ -974,7 +974,7 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form):
self._set_field_placeholders('send_subject', ['event_or_subevent', 'refund_amount', 'position_or_address',
'order', 'event'])
self._set_field_placeholders('send_message', ['event_or_subevent', 'refund_amount', 'position_or_address',
'order', 'event'])
'order', 'event'], rich=True)
self.fields['send_waitinglist_subject'] = I18nFormField(
label=_("Subject"),
required=True,
@@ -998,7 +998,7 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form):
))
)
self._set_field_placeholders('send_waitinglist_subject', ['event_or_subevent', 'event'])
self._set_field_placeholders('send_waitinglist_message', ['event_or_subevent', 'event'])
self._set_field_placeholders('send_waitinglist_message', ['event_or_subevent', 'event'], rich=True)
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
+8
View File
@@ -474,6 +474,7 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'imprint_url',
@@ -1024,6 +1025,13 @@ class OrganizerFooterLinkForm(I18nModelForm):
class Meta:
model = OrganizerFooterLink
fields = ('label', 'url')
widgets = {
"url": forms.URLInput(
attrs={
"placeholder": "https://..."
}
)
}
class BaseOrganizerFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet):
+1
View File
@@ -69,6 +69,7 @@ class UserEditForm(forms.ModelForm):
'email',
'require_2fa',
'is_active',
'is_verified',
'is_staff',
'needs_password_change',
'last_login'
+3 -3
View File
@@ -308,8 +308,8 @@ class VoucherBulkForm(VoucherForm):
)
Recipient = namedtuple('Recipient', 'email number name tag')
def _set_field_placeholders(self, fn, base_parameters):
placeholders = get_available_placeholders(self.instance.event, base_parameters)
def _set_field_placeholders(self, fn, base_parameters, rich=False):
placeholders = get_available_placeholders(self.instance.event, base_parameters, rich=rich)
ht = format_placeholders_help_text(placeholders, self.instance.event)
if self.fields[fn].help_text:
@@ -345,7 +345,7 @@ class VoucherBulkForm(VoucherForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._set_field_placeholders('send_subject', ['event', 'name'])
self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'])
self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'], rich=True)
with language(self.instance.event.settings.locale, self.instance.event.settings.region):
for f in ("send_subject", "send_message"):
+13 -1
View File
@@ -582,6 +582,7 @@ class CoreOrderLogEntryType(OrderLogEntryType):
'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.carts.deleted': _('Cart positions including the voucher have been deleted.'),
'pretix.voucher.added.waitinglist': _('The voucher has been assigned to {email} through the waiting list.'),
})
class CoreVoucherLogEntryType(VoucherLogEntryType):
@@ -667,6 +668,14 @@ class UserSettingsChangedLogEntryType(LogEntryType):
return text
@log_entry_types.new_from_dict({
'pretix.user.email.changed': _('Your email address has been changed from {old_email} to {email}.'),
'pretix.user.email.confirmed': _('Your email address {email} has been confirmed.'),
})
class UserEmailChangedLogEntryType(LogEntryType):
pass
class UserImpersonatedLogEntryType(LogEntryType):
def display(self, logentry, data):
return self.plain.format(data['other_email'])
@@ -805,7 +814,7 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
if app and hasattr(app, 'PretixPluginMeta'):
return {
'href': reverse('control:organizer.settings.plugins', kwargs={
'organizer': logentry.event.organizer.slug,
'organizer': logentry.organizer.slug,
}) + '#plugin_' + logentry.parsed_data['plugin'],
'val': app.PretixPluginMeta.name
}
@@ -882,6 +891,9 @@ class EventPluginStateLogEntryType(EventLogEntryType):
'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'),
'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'),
'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'),
'pretix.event.item.program_times.added': _('A program time has been added to this product.'),
'pretix.event.item.program_times.changed': _('A program time has been changed on this product.'),
'pretix.event.item.program_times.removed': _('A program time has been removed from this product.'),
})
class CoreItemLogEntryType(ItemLogEntryType):
pass
+2 -2
View File
@@ -72,7 +72,7 @@ class PermissionMiddleware:
)
EXCEPTIONS_FORCED_PW_CHANGE = (
"user.settings",
"user.settings.password.change",
"auth.logout"
)
@@ -139,7 +139,7 @@ class PermissionMiddleware:
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
except SessionPasswordChangeRequired:
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
return redirect_to_url(reverse('control:user.settings.password.change') + '?next=' + quote(request.get_full_path()))
except Session2FASetupRequired:
if url_name not in self.EXCEPTIONS_2FA:
return redirect_to_url(reverse('control:user.settings.2fa'))
+1 -1
View File
@@ -86,7 +86,7 @@ def get_event_navigation(request: HttpRequest):
'active': url.url_name == 'event.settings.mail',
},
{
'label': _('Tax rules'),
'label': _('Taxes'),
'url': reverse('control:event.settings.tax', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
@@ -7,6 +7,7 @@
<h3>{% trans "Set new password" %}</h3>
{% csrf_token %}
{% bootstrap_form_errors form type='all' layout='inline' %}
{% bootstrap_field form.email %}
{% bootstrap_field form.password %}
{% bootstrap_field form.password_repeat %}
<div class="form-group buttons">
@@ -126,7 +126,9 @@
{% endif %}
<a class="navbar-brand" href="{% url "control:index" %}">
<img src="{% static "pretixbase/img/pretix-icon-white-mini.svg" %}" />
{{ settings.PRETIX_INSTANCE_NAME }}
<span>
{{ settings.PRETIX_INSTANCE_NAME }}
</span>
</a>
</div>
<ul class="nav navbar-nav navbar-top-links navbar-left flip hidden-xs">
@@ -55,7 +55,7 @@
<div class="col-md-2">
{% bootstrap_field formset.empty_form.overwrite layout='inline' form_group_class="" %}
</div>
{{ f.value_map.as_hidden }}
{{ formset.empty_form.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"
@@ -0,0 +1,13 @@
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
{{ reason }}
{{ code }}
Please do never give this code to another person. Our support team will never ask for this code.
If this code was not requested by you, please contact us immediately.
Best regards,
Your pretix team
{% endblocktrans %}

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