Compare commits

...

231 Commits

Author SHA1 Message Date
robbi5 93eef9ce7e Remove duplicate device/revoke from api documentation 2026-01-27 16:12:35 +01:00
Richard Schreiber 803d0b1570 Fix missing locale in widget waitinglist 2026-01-26 16:52:37 +01:00
Raphael Michel 65fe7b3396 Bump version to 2026.2.0.dev0 2026-01-26 16:52:09 +01:00
Raphael Michel c94f7c35da Bump version to 2026.1.0 2026-01-26 16:51:58 +01:00
Ruud Hendrickx c1b9e0df42 Translations: Update Dutch
Currently translated at 99.6% (6187 of 6207 strings)

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

powered by weblate
2026-01-26 14:57:04 +01:00
Raphael Michel 47cbd74ab5 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-26 14:57:04 +01:00
Raphael Michel 32369445d0 Translations: Update German
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-26 14:57:04 +01:00
Raphael Michel bb0a6a8001 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-01-26 14:20:46 +01:00
Raphael Michel 49aade373c Fix spellcheck issues 2026-01-26 14:19:39 +01:00
Ruud Hendrickx 9dcd142112 Translations: Update Dutch
Currently translated at 99.6% (6187 of 6207 strings)

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

powered by weblate
2026-01-26 14:19:24 +01:00
Raphael Michel 68a64f577c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-26 14:19:24 +01:00
Raphael Michel 17eb6063d1 Translations: Update German
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-26 14:19:24 +01:00
Raphael Michel fd5dd989f7 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-01-26 10:10:17 +01:00
Ruud Hendrickx 7fd1d91eb8 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 10:09:05 +01:00
Raphael Michel ef500c8924 Revert "Update po files"
This reverts commit 01a3546783.
2026-01-26 10:04:32 +01:00
Raphael Michel 01a3546783 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-01-26 10:03:14 +01:00
Raphael Michel 3e0ff1e6ed Send security notification when recovery code is used or created by admin (#5719)
* Send security notification when recovery code is used or created by admin

"Where to store recovery codes" is one of these problems there is no
right answer to, so many people store them in a less-than-optimal place.
If that's the reality we live in, this PR adds at least a little
security so one notices when they get used :)

* Add sentence
2026-01-26 10:01:07 +01:00
Raphael Michel 4edc7d95c6 Address form: Add missing province Aosta for Italy (#5796) (#5800) 2026-01-26 10:00:11 +01:00
Raphael Michel 7fb9e9a33d Bump django-formset-js-improved to 0.5.0.5 2026-01-26 09:58:13 +01:00
Raphael Michel 8058461f10 Invoices: Allow issuing invoices only to businesses (Z#23220397) (#5807)
* Invoices: Allow issuing invoices only to businesses

In situations where every invoice has a significant accounting cost and
consumers usually do not need invoices, this can save a lot of money or
effort.

* Improve backend UI if not qualified for invoice
2026-01-26 09:52:19 +01:00
Raphael Michel c84bd4046d Invoice address: Make Peppol required in Belgium if available (Z#23220397) (#5808)
* Invoice address: Make Peppol required in Belgium if available (Z#23220397)

* Fix failing test, remove template bit that's now impossible
2026-01-26 09:52:06 +01:00
Raphael Michel 5e97f668a5 Order data export: Allow to filter by product (Z#23212618) (#5826)
* Order data export: Allow to filter by product (Z#23212618)

* Fix tests
2026-01-26 09:29:41 +01:00
Raphael Michel 5c8e785a6f Fix typo from merge conflict resolving 2026-01-26 09:29:22 +01:00
Raphael Michel 8e61ac6071 Invoice address: Add convenient autofill for Pepppol in Belgium (Z#23220397) (#5809)
* Invoice address: Add convenient autofill for Pepppol in Belgium (Z#23220397)

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

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-26 09:12:07 +01:00
Raphael Michel c3fd3a0838 Scheduled exports: Add copy button (Z#23221224) (#5823)
* Scheduled exports: Add copy button (Z#23221224)

* Update button label
2026-01-26 08:46:25 +01:00
Phin Wolkwitz 0d6e1e2271 Prefetch program times, add test for query count (#5822) 2026-01-26 08:38:44 +01:00
Raphael Michel 0af011eed4 Web check-in: Show addons of ticket (Z#23220213) (#5827)
* Web check-in: Show addons of ticket (Z#23220213)

* Update src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/components/app.vue

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

---------

Co-authored-by: luelista <weller@rami.io>
2026-01-26 08:37:54 +01:00
Kian Cross a0dae48cec Prevent double-clicks on SSO login providers (#5842) 2026-01-26 08:31:30 +01:00
Ruud Hendrickx a53795ea88 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud Hendrickx f1c0f24e25 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud Hendrickx 980f4712a7 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud Hendrickx bc8a8d8851 Translations: Update Dutch
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud Hendrickx 10ec4d6c29 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Vajda Tamás 584345cb99 Translations: Update Hungarian
Currently translated at 40.1% (102 of 254 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Vajda Tamás 88545bcd05 Translations: Update Hungarian
Currently translated at 10.6% (657 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud Hendrickx f034f4cde4 Translations: Update Dutch
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud Hendrickx fc6475b0bc Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Lukas Bockstaller aecc87ccdb handle open ended datetime ranges (#5838) 2026-01-23 12:25:28 +01:00
Raphael Michel 059179aecb Fix babel locale discovery for zh_Hans_US 2026-01-23 11:36:12 +01:00
Raphael Michel fd72e18a7f Overview export: Allow to skip empty lines (Z#23219200) (#5825) 2026-01-23 11:18:07 +01:00
Raphael Michel baac963fa8 API: Fix crash in check-in API (PRETIXEU-CT1) (#5806) 2026-01-23 11:17:21 +01:00
Ruud Hendrickx 461ab2472f Translations: Update Dutch
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-23 09:00:20 +01:00
Ruud Hendrickx 29d98f4182 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-23 09:00:20 +01:00
Raphael Michel 4f989cbe8a Order export: Add voucher_budget_use (Z#23218461) 2026-01-22 21:26:42 +01:00
Raphael Michel 23559e0711 Scheduled export: Move error message for missing permissions
This error message mostly occurs when working in admin mode and this
change allows our support team to still see what the form looks like to
guide users through, even if they can't save.
2026-01-22 21:26:33 +01:00
Ruud Hendrickx 8787f79274 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Jiří Pastrňák a7072d3b5b Translations: Update Czech
Currently translated at 70.0% (4340 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud Hendrickx ff47ee7d68 Translations: Update Dutch
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud Hendrickx 2c321f401d Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud Hendrickx 180b92c87f Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 69.4% (4298 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud Hendrickx c99751b319 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud Hendrickx 1f4205a9d9 Translations: Update Dutch
Currently translated at 96.1% (5952 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud Hendrickx 9e694982cf Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 67.8% (4199 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Jiří Pastrňák ea5dbb05c2 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-22 10:32:36 +01:00
Linnea Thelander e2ede76468 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-22 10:32:36 +01:00
dependabot[bot] 498f5760af Update sentry-sdk requirement from ==2.49.* to ==2.50.* (#5828)
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.49.0...2.50.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.50.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-22 10:20:41 +01:00
dependabot[bot] 3b9ae7e560 Bump pycparser from 2.23 to 3.0 (#5832)
Bumps [pycparser](https://github.com/eliben/pycparser) from 2.23 to 3.0.
- [Release notes](https://github.com/eliben/pycparser/releases)
- [Commits](https://github.com/eliben/pycparser/compare/release_v2.23...release_v3.00)

---
updated-dependencies:
- dependency-name: pycparser
  dependency-version: '3.0'
  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-22 10:20:32 +01:00
dependabot[bot] c08e3c054a Bump markdown from 3.10 to 3.10.1 (#5833)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.10 to 3.10.1.
- [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.10.0...3.10.1)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: 3.10.1
  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>
2026-01-22 10:20:24 +01:00
Raphael Michel 815e31d9a0 Resolve syntax warning in Pyton 3.14 2026-01-20 12:15:49 +01:00
Lukas Bockstaller ed618f2f32 add tiered availability by time (Z#23204747) (#5737)
* add tiered availability by time

* replace bitwise operator

* rephrase help text
2026-01-20 10:32:17 +01:00
Lukas Bockstaller a900e11ce0 Reduce queries for waitinglist autoassign n+1 (PRETIXEU-BJJ) (#5819)
* baseline of 574 queries

* reuse event from wle for locked_wle

reduces amount of queries to 556

* keep event accross refresh from db

drops queries from 556 to 471, halving the amount of queries for direct fetches for the event

* make numbers of queries reproducible by prewarming ContentTypeCache

* fix oversight

* correct number of queries to 335

* remove debug tooling

* remove assert_num_queries
2026-01-20 10:31:58 +01:00
Richard Schreiber 112d5da792 Localize state names (#5744)
* Localize state names in js-helper

* localize statename in address-confirm

* add localized state_name to AbstractPosition and AttendeeProfile

* use state_for_address in order export
2026-01-20 10:13:20 +01:00
Richard Schreiber ceb2e13d27 Remove autofocus from only button in cart-extend confirm-dialog (#5821) 2026-01-20 09:53:16 +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
275 changed files with 173288 additions and 155930 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 -15
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
@@ -208,20 +208,6 @@ Additionally, when creating a device through the user interface or API, a user c
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
policies for official pretix apps like pretixSCAN and pretixPOS.
Removing a device
-----------------
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
invalidate your API key. There is no way to reverse this operation.
.. sourcecode:: http
POST /api/v1/device/revoke HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
This can also be done by the user through the web interface.
Event selection
---------------
+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.
+22 -22
View File
@@ -46,28 +46,28 @@ Endpoints
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"
}
]
}
{
"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
+1 -1
View File
@@ -211,7 +211,7 @@ The line-based computation has a few significant advantages:
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 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation
(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.
+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 -22
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,19 +29,19 @@ 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.18.*",
"css-inline==0.19.*",
"defusedcsv>=1.1.0",
"dnspython==2.*",
"Django[argon2]==4.2.*,>=4.2.26",
"django-bootstrap3==25.2",
"django-compressor==4.5.1",
"django-countries==7.6.*",
"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-formset-js-improved==0.5.0.5",
"django-formtools==2.5.1",
"django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*",
@@ -50,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.1", # 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.*",
@@ -75,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",
"pycparser==3.0",
"pycryptodome==3.23.*",
"pypdf==6.4.*",
"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.46.*",
"sentry-sdk==2.50.*",
"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.*"
@@ -111,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",
@@ -124,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.11.0.dev0"
__version__ = "2026.2.0.dev0"
+3
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',
@@ -805,6 +806,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_reissue_after_modify',
'invoice_include_free',
'invoice_generate',
'invoice_generate_only_business',
'invoice_period',
'invoice_numbers_consecutive',
'invoice_numbers_prefix',
@@ -943,6 +945,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',
+12 -2
View File
@@ -191,7 +191,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
)
break # do not call else branch of for loop
elif t.exclusive:
elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")):
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
@@ -704,6 +704,16 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
if 'answers.question' in self.context['expand']:
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
if 'addons' in self.context['expand']:
# Experimental feature, undocumented on purpose for now in case we need to remove it again
# for performance reasons
subl = CheckinListOrderPositionSerializer(read_only=True, many=True, context={
**self.context,
'expand': [v for v in self.context['expand'] if v != 'addons'],
'pdf_data': False,
})
self.fields['addons'] = subl
class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2
@@ -1601,7 +1611,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
]
)
+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',
+38 -24
View File
@@ -381,15 +381,21 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
qs = qs.filter(reduce(operator.or_, lists_qs))
prefetch_related = [
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
]
select_related = [
'item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat'
]
if pdf_data:
qs = qs.prefetch_related(
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
# Don't add to list, we don't want to propagate to addons
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
Prefetch(
'event',
@@ -404,32 +410,39 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
)
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
)
else:
qs = qs.prefetch_related(
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
if expand and 'subevent' in expand:
qs = qs.prefetch_related(
prefetch_related += [
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values'
)
]
if expand and 'item' in expand:
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values',
'item__variations').select_related('item__tax_rule')
prefetch_related += [
'item', 'item__addons', 'item__bundles', 'item__meta_values',
'item__variations',
]
select_related.append('item__tax_rule')
if expand and 'variation' in expand:
qs = qs.prefetch_related('variation', 'variation__meta_values')
prefetch_related += [
'variation', 'variation__meta_values',
]
if expand and 'addons' in expand:
prefetch_related += [
Prefetch('addons', OrderPosition.objects.prefetch_related(*prefetch_related).select_related(*select_related)),
]
else:
prefetch_related += [
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
]
if pdf_data:
select_related.remove("order") # Don't need it twice on this queryset
qs = qs.prefetch_related(*prefetch_related).select_related(*select_related)
return qs
@@ -966,6 +979,7 @@ class CheckinRPCSearchView(ListAPIView):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['expand'] = self.request.query_params.getlist('expand')
ctx['organizer'] = self.request.organizer
ctx['pdf_data'] = False
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()
+2 -2
View File
@@ -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):
@@ -567,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 -1
View File
@@ -2031,7 +2031,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
else:
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
c = generate_cancellation(inv)
if inv.order.status != Order.STATUS_CANCELED:
if invoice_qualified(order):
inv = generate_invoice(order)
else:
inv = c
+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):
+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
+41 -6
View File
@@ -39,8 +39,8 @@ from zoneinfo import ZoneInfo
from django import forms
from django.conf import settings
from django.db.models import (
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
Q, Subquery, Sum, When,
Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, Sum, When,
)
from django.db.models.functions import Coalesce
from django.dispatch import receiver
@@ -144,6 +144,18 @@ class OrderListExporter(MultiSheetListExporter):
d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents:
del d['event_date_range']
if not self.is_multievent:
d["items"] = forms.ModelMultipleChoiceField(
label=_("Products"),
queryset=self.event.items.all(),
widget=forms.CheckboxSelectMultiple(
attrs={"class": "scrolling-multiple-choice"}
),
help_text=_("If none are selected, all products are included. Orders are included if they contain "
"at least one position of this product. The order totals etc. still include all products "
"contained in the order."),
required=False,
)
return d
def _get_all_payment_methods(self, qs):
@@ -249,6 +261,14 @@ class OrderListExporter(MultiSheetListExporter):
pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address', 'customer')
if form_data.get('items'):
qs = qs.filter(
Exists(OrderPosition.all.filter(
order=OuterRef('pk'),
item__in=form_data["items"]
))
)
qs = self._date_filter(qs, form_data, rel='')
if form_data['paid_only']:
@@ -364,7 +384,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.state_for_address,
order.invoice_address.custom_field,
order.invoice_address.vat_id,
]
@@ -440,6 +460,14 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
qs = qs.filter(
Exists(OrderPosition.all.filter(
order=OuterRef('order'),
item__in=form_data["items"]
))
)
qs = self._date_filter(qs, form_data, rel='order__')
return qs
@@ -515,7 +543,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.state_for_address,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
@@ -535,6 +563,11 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
qs = qs.filter(
item__in=form_data["items"]
)
qs = self._date_filter(qs, form_data, rel='order__')
return qs
@@ -617,6 +650,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Country'),
pgettext('address', 'State'),
_('Voucher'),
_('Voucher budget usage'),
_('Pseudonymization ID'),
_('Ticket secret'),
_('Seat ID'),
@@ -732,8 +766,9 @@ class OrderListExporter(MultiSheetListExporter):
op.zipcode or '',
op.city or '',
op.country if op.country else '',
op.state or '',
op.state_for_address or '',
op.voucher.code if op.voucher else '',
op.voucher_budget_use if op.voucher_budget_use else '',
op.pseudonymization_id,
op.secret,
]
@@ -797,7 +832,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.state_for_address,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
+29 -11
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)
@@ -1399,7 +1417,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.transmission_type = transmission_type.identifier
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
elif transmission_type.exclusive:
elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
+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 localedata.normalize_locale(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):
+3 -1
View File
@@ -36,9 +36,11 @@ class ItalianSdITransmissionType(TransmissionType):
identifier = "it_sdi"
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
exclusive = True
enforce_transmission = True
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
return str(country) == "IT"
def is_available(self, event, country: Country, is_business: bool):
return str(country) == "IT" and super().is_available(event, country, is_business)
+11 -12
View File
@@ -32,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
@@ -47,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,
@@ -60,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
@@ -235,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
@@ -1059,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
+10 -2
View File
@@ -64,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}",
@@ -73,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}",
@@ -120,7 +123,6 @@ class PeppolIdValidator:
"9951": ".*",
"9952": ".*",
"9953": ".*",
"9954": ".*",
"9956": "0[0-9]{9}",
"9957": ".*",
"9959": ".*",
@@ -177,6 +179,12 @@ class PeppolTransmissionType(TransmissionType):
def is_available(self, event, country: Country, is_business: bool):
return is_business and super().is_available(event, country, is_business)
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
if is_business and str(country) == "BE" and event and event.settings.invoice_address_from_country == "BE":
# Peppol is required to be used for intra-Belgian B2B invoices
return True
return False
@property
def invoice_address_form_fields(self) -> dict:
return {
+9 -9
View File
@@ -58,15 +58,6 @@ class TransmissionType:
"""
return 100
@property
def exclusive(self) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction.
"""
return False
@property
def enforce_transmission(self) -> bool:
"""
@@ -82,6 +73,15 @@ class TransmissionType:
for provider, _ in providers
)
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction. Event can be None in organizer-level contexts. Exclusiveness has no effect if
the type is not available.
"""
return False
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
return set()
+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()
+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
+4 -1
View File
@@ -53,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
@@ -708,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']))
@@ -737,6 +738,8 @@ class WebAuthnDevice(Device):
@property
def webauthndevice(self):
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
@property
+31
View File
@@ -59,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):
+1 -1
View File
@@ -349,7 +349,7 @@ class AttendeeProfile(models.Model):
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd:
return sd.name
return _(sd.name)
return self.state
@property
+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:
+5 -4
View File
@@ -594,10 +594,11 @@ class Item(LoggedModel):
on_delete=models.SET_NULL,
verbose_name=_("Only show after sellout of"),
help_text=_("If you select a product here, this product will only be shown when that product is "
"sold out. If combined with the option to hide sold-out products, this allows you to "
"swap out products for more expensive ones once the cheaper option is sold out. There might "
"be a short period in which both products are visible while all tickets of the referenced "
"product are reserved, but not yet sold.")
"no longer available. This will happen either because the other product has sold out or because "
"the time is outside of the sales window for the other product. If combined with the option "
"to hide sold-out products, this allows you to swap out products for more expensive ones once "
"the cheaper option is sold out. There might be a short period in which both products are visible "
"while all tickets of the referenced product are reserved, but not yet sold.")
)
hidden_if_item_available_mode = models.CharField(
choices=UNAVAIL_MODES,
+2 -1
View File
@@ -141,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,
+2 -2
View File
@@ -1675,7 +1675,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd:
return sd.name
return _(sd.name)
return self.state
@property
@@ -3480,7 +3480,7 @@ class InvoiceAddress(models.Model):
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd:
return sd.name
return _(sd.name)
return self.state
@property
+2 -1
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)
+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(
+24 -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
@@ -158,6 +159,7 @@ class WaitingListEntry(LoggedModel):
if availability[1] is None or availability[1] < 1:
raise WaitingListException(_('This product is currently not available.'))
event = self.event
ev = self.subevent or self.event
if ev.seat_category_mappings.filter(product=self.item).exists():
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
@@ -185,44 +187,49 @@ 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)
locked_wle.event = event
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()
self.event = event
with language(self.locale, self.event.settings.region):
self.send_mail(
+13 -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
@@ -85,7 +82,9 @@ 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.daterange import datetimerange
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
from pretix.helpers.reportlab import (
ThumbnailingImageReader, register_ttf_font_if_new, reshaper,
)
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
@@ -795,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
@@ -1311,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)
+209 -91
View File
@@ -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,7 +1528,7 @@ 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
]
)
@@ -1439,15 +1543,24 @@ class CartManager:
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()
@@ -1703,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 -2
View File
@@ -521,9 +521,20 @@ def invoice_pdf_task(invoice: int):
def invoice_qualified(order: Order):
if order.total == Decimal('0.00') or order.require_approval or \
order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'):
if order.total == Decimal('0.00'):
return False
if order.require_approval:
return False
if order.sales_channel.identifier not in order.event.settings.invoice_generate_sales_channels:
return False
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
return False
if order.event.settings.invoice_generate_only_business:
try:
ia = order.invoice_address
return ia.is_business
except InvoiceAddress.DoesNotExist:
return False
return True
+2 -1
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
@@ -764,6 +763,8 @@ def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_P
def replace_images_with_cid_paths(body_html):
from bs4 import BeautifulSoup
if body_html:
email = BeautifulSoup(body_html, "lxml")
cid_images = []
+59 -80
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,
@@ -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.'
@@ -182,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.'),
@@ -744,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(
@@ -774,15 +794,12 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
# 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'] % {
@@ -792,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:
@@ -806,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):
@@ -855,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)
@@ -914,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:
@@ -945,13 +908,13 @@ 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
]
)
@@ -1667,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'))
@@ -1679,6 +1642,18 @@ class OrderChangeManager:
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
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
@@ -1883,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
@@ -1942,8 +1917,11 @@ class OrderChangeManager:
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'):
@@ -2562,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)
+5 -1
View File
@@ -801,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,
+5 -3
View File
@@ -174,7 +174,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
: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
@@ -196,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)
+10 -1
View File
@@ -112,7 +112,8 @@ def dictsum(*dicts) -> dict:
def order_overview(
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None,
skip_empty_lines=False,
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related(
'category', # for re-grouping
@@ -205,13 +206,21 @@ def order_overview(
for l in states.keys():
var.num[l] = num[l].get((item.id, variid), (0, 0, 0))
var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0))
var._skip = all(v[0] == 0 for v in var.num.values())
for l in states.keys():
item.num[l] = tuplesum(var.num[l] for var in item.all_variations)
item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations)
if skip_empty_lines:
item.all_variations = [v for v in item.all_variations if not v._skip]
item._skip = not item.all_variations
else:
for l in states.keys():
item.num[l] = num[l].get((item.id, None), (0, 0, 0))
item.num['total'] = num['total'].get((item.id, None), (0, 0, 0))
item._skip = all(v[0] == 0 for v in item.num.values())
if skip_empty_lines:
items = [i for i in items if not i._skip]
nonecat = ItemCategory(name=_('Uncategorized'))
# Regroup those by category
+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)
+55 -4
View File
@@ -76,7 +76,7 @@ from pretix.base.validators import multimail_validate
from pretix.control.forms import (
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
)
from pretix.helpers.countries import CachedCountries
from pretix.helpers.countries import CachedCountries, pycountry_add
ROUNDING_MODES = (
('line', _('Compute taxes for every line individually')),
@@ -180,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,
@@ -629,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,
@@ -1185,6 +1225,15 @@ DEFAULTS = {
'default': json.dumps(['web']),
'type': list
},
'invoice_generate_only_business': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Only issue invoices to business customers"),
)
},
'invoice_address_from': {
'default': '',
'type': str,
@@ -3887,7 +3936,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
'MX': (['State', 'Federal district', 'Federal entity'], 'short'),
'US': (['State', 'Outlying area', 'District'], 'short'),
'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province',
'Free municipal consortium', 'Decentralized regional entity'], 'short'),
'Decentralized regional entity'], 'short'),
}
COUNTRY_STATE_LABEL = {
# Countries in which the "State" field should not be called "State"
@@ -3895,6 +3944,8 @@ COUNTRY_STATE_LABEL = {
'JP': pgettext_lazy('address', 'Prefecture'),
'IT': pgettext_lazy('address', 'Province'),
}
# Workaround for https://github.com/pretix/pretix/issues/5796
pycountry_add(pycountry.subdivisions, code="IT-AO", country_code="IT", name="Valle d'Aosta", parent="23", parent_code="IT-23", type="Province")
settings_hierarkey = Hierarkey(attribute_name='settings')
+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)
+28 -4
View File
@@ -32,13 +32,14 @@
# 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
@@ -124,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)))
@@ -333,8 +351,14 @@ def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED
# 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
attrs[None, "href"] = escape(format_map(attrs[None, "href"], context=context, mode=SafeFormatter.MODE_RICH_TO_PLAIN))
# 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)
+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()
+37 -4
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}
@@ -55,7 +82,7 @@ def _info(cc):
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return {
'data': [
{'name': s.name, 'code': s.code[3:]}
{'name': gettext(s.name), 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name)
],
**info,
@@ -82,7 +109,7 @@ def address_form(request):
for t in get_transmission_types():
if t.is_available(event=event, country=country, is_business=is_business):
result = {"name": str(t.public_name), "code": t.identifier}
if t.exclusive:
if t.is_exclusive(event=event, country=country, is_business=is_business):
info["transmission_types"] = [result]
break
else:
@@ -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)
+27 -6
View File
@@ -45,7 +45,7 @@ from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
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
@@ -53,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
@@ -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']
@@ -927,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',
@@ -937,6 +939,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_show_payments',
'invoice_reissue_after_modify',
'invoice_generate',
'invoice_generate_only_business',
'invoice_period',
'invoice_attendee_name',
'invoice_event_location',
@@ -1309,9 +1312,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"),
@@ -1479,6 +1490,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')
@@ -1497,7 +1511,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:
@@ -1957,6 +1971,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)
if d_start:
opqs = opqs.filter(subevent__date_from__gte=d_start)
if d_end:
opqs = opqs.filter(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',
+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 -1
View File
@@ -814,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
}
@@ -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"
@@ -12,6 +12,7 @@
<legend>{% trans "Invoice generation" %}</legend>
{% bootstrap_field form.invoice_generate layout="control" %}
{% bootstrap_field form.invoice_generate_sales_channels layout="control" %}
{% bootstrap_field form.invoice_generate_only_business layout="control" %}
{% bootstrap_field form.invoice_email_attachment layout="control" %}
{% bootstrap_field form.invoice_email_organizer layout="control" %}
{% bootstrap_field form.invoice_language layout="control" %}
@@ -43,6 +44,7 @@
{% bootstrap_field form.invoice_name_required layout="control" %}
{% bootstrap_field form.invoice_address_company_required layout="control" %}
{% bootstrap_field form.invoice_address_vatid layout="control" %}
{% bootstrap_field form.invoice_address_vatid_required_countries layout="control" %}
{% bootstrap_field form.invoice_address_beneficiary layout="control" %}
{% bootstrap_field form.invoice_address_not_asked_free layout="control" %}
{% bootstrap_field form.invoice_address_custom_field layout="control" %}
@@ -110,11 +112,6 @@
<span class="text-success">
<span class="fa fa-check fa-fw"></span>
{% trans "Available" %}
{% if t.exclusive %}
<span data-toggle="tooltip" title="{% trans "When this type is available for an invoice address, no other type can be selected." %}">
{% trans "(exclusive)" %}
</span>
{% endif %}
</span>
{% else %}
<span class="text-muted">
@@ -20,35 +20,20 @@
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="col-lg-2 col-sm-6 col-xs-6">
<select name="status" class="form-control">
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option>
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option>
<option value="pv" {% if request.GET.status == "pv" %}selected="selected"{% endif %}>{% trans "Paid or confirmed" %}</option>
<option value="n" {% if request.GET.status == "n" %}selected="selected"{% endif %}>{% trans "Pending" %}</option>
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option>
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option>
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
</select>
<div class="col-md-2 col-xs-6">
{% bootstrap_field form.status %}
</div>
<div class="col-md-3 col-xs-6">
{% bootstrap_field form.item %}
</div>
{% if has_subevents %}
<div class="col-md-3 col-xs-6">
{% bootstrap_field form.subevent %}
</div>
<div class="col-lg-5 col-sm-6 col-xs-6">
<select name="item" class="form-control">
<option value="">{% trans "All products" %}</option>
{% for item in items %}
<option value="{{ item.id }}"
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
{{ item.name }}
</option>
{% endfor %}
</select>
<div class="col-md-4 col-xs-6">
{% bootstrap_field form.date_range %}
</div>
{% if request.event.has_subevents %}
<div class="col-lg-5 col-sm-6 col-xs-6">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</div>
{% endif %}
{% endif %}
</div>
<div class="text-right">
<button class="btn btn-primary btn-lg" type="submit">
@@ -353,7 +353,7 @@
data-toggle="tooltip"
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
{% endif %}>
{% if order.status == "c" %}
{% if order.status == "c" or not invoice_qualified %}
{% trans "Generate cancellation" %}
{% else %}
{% trans "Cancel and reissue" %}
@@ -22,7 +22,7 @@
{{ s.owner.fullname|default:s.owner.email }}
</span>
</div>
<div class="col-lg-5 col-md-6 col-xs-12">
<div class="col-lg-4 col-md-5 col-xs-12">
{% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %}
@@ -53,7 +53,7 @@
{{ s.mail_subject }}
</span>
</div>
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
<div class="col-lg-3 col-md-3 col-xs-12 text-right">
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
@@ -73,6 +73,9 @@
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span>
</a>
<a href="?identifier={{ s.export_identifier }}&scheduled_copy_from={{ s.pk }}" class="btn btn-default" title="{% trans "Copy" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
{% endif %}
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span>
@@ -42,7 +42,11 @@
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% trans "Save" %}
{% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
</button>
</div>
{% else %}
@@ -132,6 +132,7 @@
<legend>{% trans "Customer accounts" %}</legend>
{% bootstrap_field sform.customer_accounts layout="control" %}
{% bootstrap_field sform.customer_accounts_native layout="control" %}
{% bootstrap_field sform.customer_accounts_require_login_for_order_access layout="control" %}
{% bootstrap_field sform.customer_accounts_link_by_email layout="control" %}
{% bootstrap_field sform.name_scheme layout="control" %}
{% bootstrap_field sform.name_scheme_titles layout="control" %}
@@ -22,7 +22,7 @@
{{ s.owner.fullname|default:s.owner.email }}
</span>
</div>
<div class="col-lg-5 col-md-6 col-xs-12">
<div class="col-lg-4 col-md-5 col-xs-12">
{% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %}
@@ -53,7 +53,7 @@
{{ s.mail_subject }}
</span>
</div>
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
<div class="col-lg-3 col-md-3 col-xs-12 text-right">
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
@@ -73,6 +73,9 @@
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span>
</a>
<a href="?identifier={{ s.export_identifier }}&scheduled_copy_from={{ s.pk }}" class="btn btn-default" title="{% trans "Copy" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
{% endif %}
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span>
@@ -43,7 +43,11 @@
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% trans "Save" %}
{% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
</button>
</div>
{% else %}
@@ -165,7 +165,7 @@
{% if v.budget|default_if_none:"NONE" != "NONE" %}
<br>
<small class="text-muted">
{{ v.budget_used_orders|money:request.event.currency }} / {{ v.budget|money:request.event.currency }}
{{ v.budget_used|money:request.event.currency }} / {{ v.budget|money:request.event.currency }}
</small>
{% endif %}
</td>
+6 -1
View File
@@ -57,6 +57,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from django_otp import match_token
from django_otp.plugins.otp_static.models import StaticDevice
from webauthn.helpers import generate_challenge
from pretix.base.auth import get_auth_backends
@@ -363,7 +364,7 @@ class Forgot(TemplateView):
else:
messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions.'))
return redirect('control:auth.forgot')
return redirect('control:auth.forgot')
else:
return self.get(request, *args, **kwargs)
@@ -538,6 +539,10 @@ class Login2FAView(TemplateView):
break
else:
valid = match_token(self.user, token)
if isinstance(valid, StaticDevice):
self.user.send_security_notice([
_("A recovery code for two-factor authentification was used to log in.")
])
if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
+2 -1
View File
@@ -60,7 +60,6 @@ from pretix.base.models import (
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
from pretix.control.forms.event import CommentForm
from pretix.control.signals import (
event_dashboard_widgets, user_dashboard_widgets,
)
@@ -341,6 +340,8 @@ def welcome_wizard_widget(sender, **kwargs):
def event_index(request, organizer, event):
from pretix.control.forms.event import CommentForm
subevent = None
if request.GET.get("subevent", "") != "" and request.event.has_subevents:
i = request.GET.get("subevent", "")
+12 -3
View File
@@ -98,7 +98,6 @@ from pretix.control.views.mailsetup import MailSettingsSetupView
from pretix.control.views.user import RecentAuthenticationRequiredMixin
from pretix.helpers.database import rolledback_transaction
from pretix.multidomain.urlreverse import build_absolute_uri, get_event_domain
from pretix.plugins.stripe.payment import StripeSettingsHolder
from pretix.presale.views.widget import (
version_default as widget_version_default,
)
@@ -830,8 +829,8 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
return locales
# get all supported placeholders with dummy values
def placeholders(self, item):
return get_sample_context(self.request.event, MailSettingsForm.base_context[item])
def placeholders(self, item, rich=True):
return get_sample_context(self.request.event, MailSettingsForm.base_context[item], rich=rich)
def post(self, request, *args, **kwargs):
preview_item = request.POST.get('item', '')
@@ -852,6 +851,14 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
msgs[self.supported_locale[idx]] = prefix_subject(self.request.event, format_map(
bleach.clean(v), self.placeholders(preview_item), raise_on_missing=True
), highlight=True)
elif preview_item in MailSettingsForm.plain_rendering:
msgs[self.supported_locale[idx]] = mark_safe(
format_map(
conditional_escape(v),
self.placeholders(preview_item, rich=False),
raise_on_missing=True
).replace("\n", "<br />")
)
else:
placeholders = self.placeholders(preview_item)
msgs[self.supported_locale[idx]] = format_map(
@@ -1666,6 +1673,8 @@ class QuickSetupView(FormView):
'or take your event live to start selling!'))
if form.cleaned_data.get('payment_stripe__enabled', False):
from pretix.plugins.stripe.payment import StripeSettingsHolder
self.request.session['payment_stripe_oauth_enable'] = True
return redirect(StripeSettingsHolder(self.request.event).get_connect_url(self.request))
@@ -188,6 +188,8 @@ class LicenseCheckView(StaffMemberRequiredMixin, FormView):
return None, None
try:
for k, v in pkg.metadata.items():
if k == "License-Expression":
license = v
if k == "License":
license = v
if k == "Home-page":
+13 -30
View File
@@ -65,7 +65,7 @@ from pretix.api.serializers.item import (
)
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, Order,
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher,
)
@@ -74,6 +74,7 @@ from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
from pretix.control.forms.filter import QuestionAnswerFilterForm
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
@@ -660,46 +661,26 @@ class QuestionMixin:
return ctx
class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingView, DetailView):
class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView):
model = Question
template_name = 'pretixcontrol/items/question.html'
permission = 'can_change_items'
template_name_field = 'question'
@cached_property
def filter_form(self):
return QuestionAnswerFilterForm(event=self.request.event, data=self.request.GET)
def get_answer_statistics(self):
opqs = OrderPosition.objects.filter(
order__event=self.request.event,
)
if self.filter_form.is_valid():
opqs = self.filter_form.filter_qs(opqs)
qs = QuestionAnswer.objects.filter(
question=self.object, orderposition__isnull=False,
)
if self.request.GET.get("subevent", "") != "":
opqs = opqs.filter(subevent=self.request.GET["subevent"])
s = self.request.GET.get("status", "np")
if s != "":
if s == 'o':
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'pv':
opqs = opqs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
elif s == 'ne':
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 self.request.GET.get("item", "") != "":
i = self.request.GET.get("item", "")
opqs = opqs.filter(item_id__in=(i,))
qs = qs.filter(orderposition__in=opqs)
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
@@ -746,9 +727,11 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['items'] = self.object.items.all()
ctx['items'] = self.object.items.exists()
ctx['has_subevents'] = self.request.event.has_subevents
stats = self.get_answer_statistics()
ctx['stats'], ctx['total'] = stats
ctx['form'] = self.filter_form
return ctx
def get_object(self, queryset=None) -> Question:
+17 -3
View File
@@ -38,6 +38,7 @@ from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
@@ -85,6 +86,7 @@ class BaseImportView(TemplateView):
filename='import.csv',
type='text/csv',
)
cf.bind_to_session(request, "modelimport")
cf.file.save('import.csv', request.FILES['file'])
if self.request.POST.get("charset") in ENCODINGS:
@@ -137,7 +139,10 @@ class BaseProcessView(AsyncAction, FormView):
@cached_property
def file(self):
return get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
cf = get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
if not cf.allowed_for_session(self.request, "modelimport"):
raise Http404()
return cf
@cached_property
def parsed(self):
@@ -146,7 +151,7 @@ class BaseProcessView(AsyncAction, FormView):
else:
charset = None
try:
return parse_csv(self.file.file, 1024 * 1024, charset=charset)
reader = parse_csv(self.file.file, 1024 * 1024, charset=charset)
except UnicodeDecodeError:
messages.warning(
self.request,
@@ -155,7 +160,16 @@ class BaseProcessView(AsyncAction, FormView):
"Some characters were replaced with a placeholder."
)
)
return parse_csv(self.file.file, 1024 * 1024, "replace", charset=charset)
reader = parse_csv(self.file.file, 1024 * 1024, "replace", charset=charset)
if reader and reader._had_duplicates:
messages.warning(
self.request,
_(
"Multiple columns of the CSV file have the same name and were renamed automatically. We "
"recommend that you rename these in your source file to avoid problems during import."
)
)
return reader
@cached_property
def parsed_list(self):
+42 -26
View File
@@ -33,6 +33,7 @@
# 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 copy
import json
import logging
import mimetypes
@@ -508,9 +509,10 @@ class OrderView(EventPermissionRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['can_generate_invoice'] = invoice_qualified(self.order) and (
ctx['invoice_qualified'] = invoice_qualified(self.order)
ctx['can_generate_invoice'] = ctx['invoice_qualified'] and (
self.request.event.settings.invoice_generate in ('admin', 'user', 'paid', 'user_paid', 'True')
) and self.order.status in (Order.STATUS_PAID, Order.STATUS_PENDING) and (
) and (
not self.order.invoices.exists()
or self.order.invoices.filter(is_cancellation=True).count() >= self.order.invoices.filter(is_cancellation=False).count()
)
@@ -1741,14 +1743,15 @@ class OrderInvoiceReissue(OrderView):
messages.error(self.request, _('The invoice has been cleaned of personal data.'))
else:
c = generate_cancellation(inv)
if order.status not in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
if invoice_qualified(order):
inv = generate_invoice(order)
messages.success(self.request, _('The invoice has been reissued.'))
else:
inv = c
messages.success(self.request, _('The invoice has been canceled.'))
order.log_action('pretix.event.order.invoice.reissued', user=self.request.user, data={
'invoice': inv.pk
})
messages.success(self.request, _('The invoice has been reissued.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs): # NOQA
@@ -2677,8 +2680,8 @@ class ExportMixin:
if id != ex.identifier:
continue
if self.scheduled:
initial = dict(self.scheduled.export_form_data)
if self.scheduled or self.scheduled_copy_from:
initial = dict((self.scheduled or self.scheduled_copy_from).export_form_data)
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
@@ -2721,6 +2724,11 @@ class ExportMixin:
elif "scheduled" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
@cached_property
def scheduled_copy_from(self):
if "scheduled_copy_from" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled_copy_from"))
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['exporters'] = self.exporters
@@ -2790,7 +2798,16 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
@transaction.atomic()
def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save":
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
if not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
return super().get(request, *args, **kwargs)
elif self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
self.schedule_form.instance.export_identifier = self.exporter.identifier
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
@@ -2829,6 +2846,8 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
def rrule_form(self):
if self.scheduled:
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
elif self.scheduled_copy_from:
initial = RRuleForm.initial_from_rrule(self.scheduled_copy_from.schedule_rrule)
else:
initial = {}
return RRuleForm(
@@ -2839,11 +2858,15 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
@cached_property
def schedule_form(self):
instance = self.scheduled or ScheduledEventExport(
event=self.request.event,
owner=self.request.user,
)
if not self.scheduled:
if self.scheduled_copy_from:
instance = copy.copy(self.scheduled_copy_from)
instance.pk = None
else:
instance = self.scheduled or ScheduledEventExport(
event=self.request.event,
owner=self.request.user,
)
if not self.scheduled and not self.scheduled_copy_from:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
@@ -2868,18 +2891,11 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if "schedule" in self.request.POST or self.scheduled:
if "schedule" in self.request.POST and not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
else:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
if "schedule" in self.request.POST or self.scheduled or self.scheduled_copy_from:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
ctx['scheduled_copy_from'] = self.scheduled_copy_from
elif not self.exporter:
for s in ctx['scheduled']:
try:
@@ -3016,7 +3032,7 @@ class EventCancel(EventPermissionRequiredMixin, AsyncAction, FormView):
return _('All orders have been canceled.')
else:
return _('The orders have been canceled. An error occurred with {count} orders, please '
'check all uncanceled orders.').format(count=value)
'check all uncanceled orders.').format(count=value["failed"])
def get_success_url(self, value):
if settings.HAS_CELERY:
@@ -3097,7 +3113,7 @@ class EventCancelConfirm(EventPermissionRequiredMixin, AsyncAction, FormView):
return _('All orders have been canceled.')
else:
return _('The orders have been canceled. An error occurred with {count} orders, please '
'check all uncanceled orders.').format(count=value)
'check all uncanceled orders.').format(count=value["failed"])
def get_success_url(self, value):
return reverse('control:event.cancel', kwargs={
+34 -21
View File
@@ -32,6 +32,7 @@
# 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 copy
import json
import logging
import re
@@ -1943,8 +1944,8 @@ class ExportMixin:
for ex in self.exporters:
if id != ex.identifier:
continue
if self.scheduled:
initial = dict(self.scheduled.export_form_data)
if self.scheduled or self.scheduled_copy_from:
initial = dict((self.scheduled or self.scheduled_copy_from).export_form_data)
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
@@ -2047,6 +2048,11 @@ class ExportMixin:
elif "scheduled" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
@cached_property
def scheduled_copy_from(self):
if "scheduled_copy_from" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled_copy_from"))
class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView):
known_errortypes = ['ExportError', 'ExportEmptyError']
@@ -2113,7 +2119,16 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
@transaction.atomic()
def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save":
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
if not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
return super().get(request, *args, **kwargs)
elif self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
self.schedule_form.instance.export_identifier = self.exporter.identifier
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
@@ -2151,6 +2166,8 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
def rrule_form(self):
if self.scheduled:
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
elif self.scheduled_copy_from:
initial = RRuleForm.initial_from_rrule(self.scheduled_copy_from.schedule_rrule)
else:
initial = {}
return RRuleForm(
@@ -2162,11 +2179,15 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
@cached_property
def schedule_form(self):
instance = self.scheduled or ScheduledOrganizerExport(
organizer=self.request.organizer,
owner=self.request.user,
timezone=str(get_current_timezone()),
)
if self.scheduled_copy_from:
instance = copy.copy(self.scheduled_copy_from)
instance.pk = None
else:
instance = self.scheduled or ScheduledOrganizerExport(
organizer=self.request.organizer,
owner=self.request.user,
timezone=str(get_current_timezone()),
)
if not self.scheduled:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
@@ -2199,18 +2220,10 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if "schedule" in self.request.POST or self.scheduled:
if "schedule" in self.request.POST and not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
else:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
if "schedule" in self.request.POST or self.scheduled or self.scheduled_copy_from:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
ctx['scheduled_copy_from'] = self.scheduled_copy_from
elif not self.exporter:
for s in ctx['scheduled']:
try:
@@ -2583,7 +2596,7 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
def get_queryset(self):
# technically, we'd also need to sort by pk since this is a paginated list, but in this case we just can't
# bear the performance cost
qs = self.request.organizer.all_logentries().select_related(
qs = self.request.organizer.logentry_set.filter(event=None).select_related(
'user', 'content_type', 'api_token', 'oauth_application', 'device'
).order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
+1 -1
View File
@@ -247,7 +247,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
cf = None
if request.POST.get("background", "").strip():
try:
cf = CachedFile.objects.get(id=request.POST.get("background"))
cf = CachedFile.objects.get(id=request.POST.get("background"), web_download=True)
except CachedFile.DoesNotExist:
pass
+5 -2
View File
@@ -38,7 +38,8 @@ from collections import OrderedDict
from zipfile import ZipFile
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.http import Http404
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import get_language, gettext_lazy as _
@@ -94,6 +95,8 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
cf = CachedFile.objects.get(pk=kwargs['file'])
except CachedFile.DoesNotExist:
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
if not cf.allowed_for_session(self.request):
raise Http404()
with ZipFile(cf.file.file, 'r') as zipfile:
indexdata = json.loads(zipfile.read('index.json').decode())
@@ -111,7 +114,7 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
ctx = super().get_context_data(**kwargs)
ctx['shredders'] = self.shredders
ctx['download_on_shred'] = any(shredder.require_download_confirmation for shredder in shredders)
ctx['file'] = get_object_or_404(CachedFile, pk=kwargs.get("file"))
ctx['file'] = cf
return ctx
+1 -1
View File
@@ -569,7 +569,7 @@ def category_select2(request, **kwargs):
page = 1
qs = request.event.categories.filter(
name__icontains=i18ncomp(query)
Q(name__icontains=i18ncomp(query)) | Q(internal_name__icontains=query)
).order_by('name')
total = qs.count()
+4
View File
@@ -165,6 +165,10 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency')
token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890'))
self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user)
self.object.send_security_notice([
_('A two-factor emergency code has been generated by a system administrator. This will usually happen '
'if you lost access to your two-factor credentials and requested a reset of the credentials.')
])
messages.success(request, _(
'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit '
+1 -1
View File
@@ -87,7 +87,7 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
def get_queryset(self):
qs = Voucher.annotate_budget_used_orders(self.request.event.vouchers.exclude(
qs = Voucher.annotate_budget_used(self.request.event.vouchers.exclude(
Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk')))
).select_related(
'item', 'variation', 'seat'
+23 -4
View File
@@ -25,7 +25,9 @@ from django.utils import translation
from django.utils.translation import gettext_noop
from django_countries import Countries, collator
from django_countries.fields import CountryField
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
from phonenumbers import (
COUNTRY_CODE_TO_REGION_CODE, REGION_CODE_FOR_NON_GEO_ENTITY,
)
from pretix.base.i18n import get_babel_locale, get_language_without_region
@@ -40,6 +42,10 @@ class CachedCountries(Countries):
django-countries performs a unicode-aware sorting based on pyuca which is incredibly
slow.
"""
# Starting in django-countries 8.1, django-countries implemented a similar caching, but only on object level.
# We keep our caching for now for the added caching to the cache store. Unfortunately we can't really avoid
# the double-caching on object level if we want the caches od be used in a sensible order. We could re-evaluate
# and drop this in the future if it ever causes bugs or memory issues.
cache_key = "countries:all:{}".format(get_language_without_region())
if self.cache_subkey:
cache_key += ":" + self.cache_subkey
@@ -84,8 +90,6 @@ class FastCountryField(CountryField):
*self._check_backend_specific_checks(**kwargs),
*self._check_validators(),
*self._check_deprecation_details(),
*self._check_multiple(),
*self._check_max_length_attribute(**kwargs),
]
@@ -107,9 +111,11 @@ def get_phone_prefixes_sorted_and_localized():
val = []
locale = Locale(translation.to_locale(language))
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
prefix = "+%d" % prefix
for country_code in values:
if country_code == REGION_CODE_FOR_NON_GEO_ENTITY:
continue
country_name = locale.territories.get(country_code)
if country_name:
val.append((prefix, "{} {}".format(country_name, prefix)))
@@ -130,3 +136,16 @@ custom_translations = [
gettext_noop("North Macedonia"),
gettext_noop("Macao"),
]
def pycountry_add(db, **kw):
# Workaround for https://github.com/pycountry/pycountry/issues/281
db._load()
obj = db.factory(**kw)
db.objects.append(obj)
for key, value in kw.items():
if key in db.no_index:
continue
value = value.lower()
index = db.indices.setdefault(key, {})
index.setdefault(value, set()).add(obj)
+2 -1
View File
@@ -229,7 +229,8 @@ def get_language_score(locale):
if not catalog:
score = 1
else:
score = len(list(catalog.items())) or 1
source_strings = [k[1] if isinstance(k, tuple) else k for k in catalog.keys()]
score = len(set(source_strings)) or 1
return score
+210
View File
@@ -0,0 +1,210 @@
#
# 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 urllib.parse import quote, urlencode
import text_unidecode
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
def dotdecimal(value):
return str(value).replace(",", ".")
def commadecimal(value):
return str(value).replace(".", ",")
def generate_payment_qr_codes(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
out = []
for method in [
swiss_qrbill,
czech_spayd,
euro_epc_qr,
euro_bezahlcode,
]:
data = method(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
)
if data:
out.append(data)
return out
def euro_epc_qr(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
if event.currency != 'EUR' or not bank_details_sepa_iban:
return
return {
"id": "girocode",
"label": "EPC-QR",
"qr_data": "\n".join(text_unidecode.unidecode(str(d or '')) for d in [
"BCD", # Service Tag: BCD
"002", # Version: V2
"2", # Character set: ISO 8859-1
"SCT", # Identification code: SCT
bank_details_sepa_bic, # AT-23 BIC of the Beneficiary Bank
bank_details_sepa_name, # AT-21 Name of the Beneficiary
bank_details_sepa_iban, # AT-20 Account number of the Beneficiary
f"{event.currency}{dotdecimal(amount)}", # AT-04 Amount of the Credit Transfer in Euro
"", # AT-44 Purpose of the Credit Transfer
"", # AT-05 Remittance Information (Structured)
code, # AT-05 Remittance Information (Unstructured)
"", # Beneficiary to originator information
"",
]),
}
def euro_bezahlcode(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
if not bank_details_sepa_iban or bank_details_sepa_iban[:2] != 'DE':
return
if event.currency != 'EUR':
return
qr_data = "bank://singlepaymentsepa?" + urlencode({
"name": str(bank_details_sepa_name),
"iban": str(bank_details_sepa_iban),
"bic": str(bank_details_sepa_bic),
"amount": commadecimal(amount),
"reason": str(code),
"currency": str(event.currency),
}, quote_via=quote)
return {
"id": "bezahlcode",
"label": "BezahlCode",
"qr_data": mark_safe(qr_data),
"link": qr_data,
"link_aria_label": _("Open BezahlCode in your banking app to start the payment process."),
}
def swiss_qrbill(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
if not bank_details_sepa_iban or not bank_details_sepa_iban[:2] in ('CH', 'LI'):
return
if event.currency not in ('EUR', 'CHF'):
return
if not event.settings.invoice_address_from or not event.settings.invoice_address_from_country:
return
data_fields = [
'SPC',
'0200',
'1',
bank_details_sepa_iban,
'K',
bank_details_sepa_name[:70],
event.settings.invoice_address_from.replace('\n', ', ')[:70],
(event.settings.invoice_address_from_zipcode + ' ' + event.settings.invoice_address_from_city)[:70],
'',
'',
str(event.settings.invoice_address_from_country),
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
str(amount),
event.currency,
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'NON',
'', # structured reference
code,
'EPD',
]
data_fields = [text_unidecode.unidecode(d or '') for d in data_fields]
qr_data = '\r\n'.join(data_fields)
return {
"id": "qrbill",
"label": "QR-bill",
"html_prefix": mark_safe(
'<svg class="banktransfer-swiss-cross" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.8 19.8">'
'<path stroke="#fff" stroke-width="1.436" d="M.7.7h18.4v18.4H.7z"/><path fill="#fff" d="M8.3 4h3.3v11H8.3z"/>'
'<path fill="#fff" d="M4.4 7.9h11v3.3h-11z"/></svg>'
),
"qr_data": qr_data,
"css_class": "banktransfer-swiss-cross-overlay",
}
def czech_spayd(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
if not bank_details_sepa_iban or not bank_details_sepa_iban[:2] in ('CZ', 'SK'):
return
if event.currency not in ('EUR', 'CZK'):
return
qr_data = f"SPD*1.0*ACC:{bank_details_sepa_iban}*AM:{dotdecimal(amount)}*CC:{event.currency}*MSG:{code}"
return {
"id": "spayd",
"label": "SPAYD",
"qr_data": qr_data,
}
+8
View File
@@ -100,3 +100,11 @@ class FontFallbackParagraph(Paragraph):
if (original_font.endswith("Bd") or original_font.endswith(" B")) and "bold" in styles:
return family + " B"
return family
def register_ttf_font_if_new(name, path):
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
if name not in pdfmetrics.getRegisteredFontNames():
pdfmetrics.registerFont(TTFont(name, path))
File diff suppressed because it is too large Load Diff
+15 -5
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -140,7 +140,7 @@ msgstr ""
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
msgid "Confirming your payment …"
msgstr ""
@@ -149,16 +149,26 @@ msgid "Payment method unavailable"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr ""
@@ -344,7 +354,7 @@ msgstr ""
msgid "close"
msgstr ""
#: pretix/static/pretixbase/js/addressform.js:98
#: pretix/static/pretixbase/js/addressform.js:101
#: pretix/static/pretixpresale/js/ui/main.js:529
msgid "required"
msgstr ""
File diff suppressed because it is too large Load Diff
+15 -5
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -144,7 +144,7 @@ msgstr "المتابعة"
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
msgid "Confirming your payment …"
msgstr "جاري تأكيد الدفع الخاص بك …"
@@ -153,16 +153,26 @@ msgid "Payment method unavailable"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders"
msgstr "طلبات مختارة"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders"
msgstr "الطلبات المدفوعة"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr "إجمالي الإيرادات"
@@ -359,7 +369,7 @@ msgstr "لا"
msgid "close"
msgstr "إغلاق"
#: pretix/static/pretixbase/js/addressform.js:98
#: pretix/static/pretixbase/js/addressform.js:101
#: pretix/static/pretixpresale/js/ui/main.js:529
msgid "required"
msgstr "مطلوب"
File diff suppressed because it is too large Load Diff
+15 -5
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -140,7 +140,7 @@ msgstr ""
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
msgid "Confirming your payment …"
msgstr ""
@@ -149,16 +149,26 @@ msgid "Payment method unavailable"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr ""
@@ -344,7 +354,7 @@ msgstr ""
msgid "close"
msgstr ""
#: pretix/static/pretixbase/js/addressform.js:98
#: pretix/static/pretixbase/js/addressform.js:101
#: pretix/static/pretixpresale/js/ui/main.js:529
msgid "required"
msgstr ""

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