Compare commits

..

192 Commits

Author SHA1 Message Date
Raphael Michel 7faf6cfd80 Bump to 2026.5.2 2026-06-25 16:40:44 +02:00
Raphael Michel cdeb3609f3 [SECURITY] Properly escape HTML tags in PDF generation (CVE-2026-57535) 2026-06-25 16:40:28 +02:00
Raphael Michel 53d8aa5679 [SECURITY] Prevent reading of any local files in reportlab (CVE-2026-57535) 2026-06-25 16:40:00 +02:00
Raphael Michel 80601cc013 [SECURITY] Disable outbound and file access for reportlab (CVE-2026-57535) 2026-06-25 16:40:00 +02:00
Mira Weller e2661f304d [SECURITY] Fix reflected XSS in redirection page (CVE-2026-57533) 2026-06-25 16:40:00 +02:00
Mira Weller 71f6ade5d6 [SECURITY] Fix stored XSS in ticket confirmation page (CVE-2026-13225) 2026-06-25 16:40:00 +02:00
Mira Weller e087dd39b8 [SECURITY] Hardening: Don't use |safe on confirm_messages 2026-06-25 16:40:00 +02:00
Mira Weller f3a8ce39f1 [SECURITY] Fix XSS in ticket layout JSON (CVE-2026-57532) 2026-06-25 16:40:00 +02:00
Raphael Michel a6c417eb2f Bump version to 2026.5.1 2026-06-09 13:22:17 +02:00
Richard Schreiber 5d449ea313 [SECURITY] Reusable media export: Respect giftcard permissions (CVE-2026-11764) (#6261) 2026-06-09 13:21:41 +02:00
Raphael Michel dfd388ddeb Bump version to 2026.5.0 2026-05-27 18:15:48 +02:00
Raphael Michel 8af010e5b7 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-05-27 17:49:23 +02:00
Raphael Michel 4f4ea01bc0 Untranslate vite setting 2026-05-27 17:47:17 +02:00
Raphael Michel 9e4cbcb372 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6304 of 6304 strings)

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

powered by weblate
2026-05-27 17:47:09 +02:00
Raphael Michel 07d7264bc6 Translations: Update German
Currently translated at 100.0% (6304 of 6304 strings)

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

powered by weblate
2026-05-27 17:47:09 +02:00
Raphael Michel f3b3eba8b3 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-05-27 16:47:27 +02:00
Raphael Michel a75dbb5d62 SSRF protection: Block requests to CGNAT addresses (Z#23235334) (#6220) 2026-05-27 16:45:11 +02:00
dependabot[bot] 254f46d991 Update pytest-asyncio requirement from >=1.3.0 to >=1.4.0 (#6221)
Updates the requirements on [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest-asyncio/releases)
- [Commits](https://github.com/pytest-dev/pytest-asyncio/compare/v1.3.0...v1.4.0)

---
updated-dependencies:
- dependency-name: pytest-asyncio
  dependency-version: 1.4.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-05-27 16:44:46 +02:00
Nikolai 59d15b0411 Translations: Update Danish
Currently translated at 60.8% (3830 of 6295 strings)

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

powered by weblate
2026-05-27 16:44:38 +02:00
Hijiri Umemoto f0778df911 Translations: Update Chinese (Traditional Han script)
Currently translated at 95.7% (245 of 256 strings)

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

powered by weblate
2026-05-27 16:44:38 +02:00
Hijiri Umemoto eb4d85c83d Translations: Update Chinese (Traditional Han script)
Currently translated at 90.2% (5680 of 6295 strings)

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

powered by weblate
2026-05-27 16:44:38 +02:00
Raphael Michel 0fe00fed27 Update GitLab CI instructions 2026-05-27 16:40:54 +02:00
Raphael Michel 7b9d095f4e [SECURITY] Add missing session check for cached files (CVE-2026-9712) 2026-05-27 16:27:42 +02:00
rash 94aec6f511 fixes bug in checkin rules editor where jquery select2 does not get removed correctly from DOM (#6206) 2026-05-26 19:33:31 +02:00
dependabot[bot] ed25a8b073 Update protobuf requirement from ==7.34.* to ==7.35.* (#6197)
Updates the requirements on [protobuf](https://github.com/protocolbuffers/protobuf) to permit the latest version.
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Commits](https://github.com/protocolbuffers/protobuf/commits)

---
updated-dependencies:
- dependency-name: protobuf
  dependency-version: 7.35.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-05-26 11:58:09 +02:00
dependabot[bot] c9eb936d45 Update pyjwt requirement from ==2.12.* to ==2.13.* (#6213)
Updates the requirements on [pyjwt](https://github.com/jpadilla/pyjwt) to permit the latest version.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.12.0...2.13.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.13.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-05-26 11:57:58 +02:00
luelista 7237ece1ca Remove duplicated csrftoken cookies (#6212)
Due to a Safari bug, in some browser, two csrftoken cookies with different values
exist: one unpartitioned, one partitioned ("CHIPS"). This function generates an
additional Set-Cookie header to get rid of the unpartitioned one.

As Django usually only allows one Set-Cookie header per cookie name, we
need to manually create a cookie 'Morsel' for the deletion and store it
in the HttpResponse's cookie dictionary under a different name, so it is
not overwritten by the actual, correct Set-Cookie header. This works
because the code in django.core.handlers.wsgi/asgi, that generates the
actual Set-Cookie headers, only iterates over cookie.values(), ignoring
the keys.
2026-05-22 17:22:56 +02:00
luelista 18485f5d95 Only show "valid VAT ID" checkmark if VAT ID was entered (Z#23235033) (#6200) 2026-05-22 15:57:21 +02:00
Richard Schreiber 909ce5b27d Add NoUrlValidator to validators (#6208) 2026-05-22 13:51:28 +02:00
Raphael Michel c7b82fdc97 Subevent update: Save SubEvent model before saving plugin forms (#6209) 2026-05-22 13:22:04 +02:00
pajowu da380ed75e Show invoice_dirty status on order details page (Z#23230731) (#6174) 2026-05-22 10:25:30 +02:00
George Hickman 687c7e3ccf Pin node version to 24 with .node-version (#6207) 2026-05-22 09:34:30 +02:00
robbi5 484b7141d9 Show device name on order print log (#6198) 2026-05-22 09:02:59 +02:00
Richard Schreiber f60031d67b Make disable_url optional for notifications (#6202) 2026-05-22 07:59:56 +02:00
pajowu dd29063a84 banktransfer import: Fix prefix confusion if shorter event name contains dash (Z#23234167) (#6189)
Co-authored-by: luelista <weller@rami.io>
2026-05-21 13:03:30 +02:00
Phumraphee Sae-tang f37dfbd21a Translations: Update Thai
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-05-21 10:59:46 +02:00
Phumraphee Sae-tang bb8ef00d49 Translations: Update Thai
Currently translated at 36.4% (2297 of 6295 strings)

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

powered by weblate
2026-05-21 10:59:46 +02:00
Nikolai d13c654596 Translations: Update Danish
Currently translated at 60.7% (3825 of 6295 strings)

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

powered by weblate
2026-05-21 10:59:46 +02:00
Khalid Shaheen 2cc73baa99 Translations: Update Arabic
Currently translated at 58.3% (3673 of 6295 strings)

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

powered by weblate
2026-05-21 10:59:46 +02:00
Richard Schreiber f740d46d47 Add template-filter human_readable_locale (#6193) 2026-05-20 12:57:57 +02:00
Martin Gross 412a5adf8f Control: Fix typo in gateSelectURL 2026-05-19 22:03:35 +02:00
dependabot[bot] e4da2e5e03 Update reportlab requirement from ==4.4.* to ==4.5.* (#6138)
Updates the requirements on [reportlab](https://www.reportlab.com/) to permit the latest version.

---
updated-dependencies:
- dependency-name: reportlab
  dependency-version: 4.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-05-19 18:03:32 +02:00
Richard Schreiber 9d7038b127 Force async_task_is_download to be downloaded if in iframe (Z#23234427) (#6194)
* Force async_task_is_download to be downloaded if in iframe (Z#23234427)

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

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

---------

Co-authored-by: luelista <weller@rami.io>
2026-05-19 17:07:54 +02:00
Richard Schreiber ce5af572cc Improve error messages for name-parts inputs (Z#23234440) (#6187)
* Improve error messages for name-parts inputs (Z#23234440)

* fix isort after flake8

* correct spelling of .med in user-provided title/name

* fix search instead of match
2026-05-19 15:23:30 +02:00
dependabot[bot] 6d293e544e Bump django-formtools from 2.5.1 to 2.6.1 (#6191)
Bumps [django-formtools](https://github.com/jazzband/django-formtools) from 2.5.1 to 2.6.1.
- [Changelog](https://github.com/jazzband/django-formtools/blob/master/docs/changelog.rst)
- [Commits](https://github.com/jazzband/django-formtools/compare/2.5.1...2.6.1)

---
updated-dependencies:
- dependency-name: django-formtools
  dependency-version: 2.6.1
  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-05-19 11:36:49 +02:00
dependabot[bot] 28a8032adf Update sentry-sdk requirement from ==2.59.* to ==2.60.* (#6190)
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.59.0a1...2.60.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.60.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-05-19 11:36:25 +02:00
dependabot[bot] d765a89139 Update djangorestframework requirement from ==3.16.* to ==3.17.* (#6013)
* Update djangorestframework requirement from ==3.16.* to ==3.17.*

Updates the requirements on [djangorestframework](https://github.com/encode/django-rest-framework) to permit the latest version.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.16.0...3.17.0)

---
updated-dependencies:
- dependency-name: djangorestframework
  dependency-version: 3.17.0
  dependency-type: direct:production
...

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

* Fix failing test

---------

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-05-18 17:58:43 +02:00
Richard Schreiber 3df5b1d075 Add cssclass for footer-nav and improve button-style in footer (#6167) 2026-05-18 13:16:50 +02:00
Martin Gross 857791445f Docs: Add Exhibitor API docs (Z#23225216) (#6184)
* Docs: Add Exhibitor API docs

* Docs: Add Resource-table for Exhibitor vouchers
2026-05-18 12:02:27 +02:00
dependabot[bot] 52b28997a2 Update fakeredis requirement from ==2.34.* to ==2.35.* (#6072)
* Update fakeredis requirement from ==2.34.* to ==2.35.*

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.34.0...v2.35.0)

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

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

* Update class name

---------

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-05-18 09:48:51 +02:00
dependabot[bot] f65a6aa11f Update cryptography requirement from >=47.0.0 to >=48.0.0 (#6177)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/47.0.0...48.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.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-05-17 16:39:18 +02:00
dependabot[bot] 9faca5ea24 Update sentry-sdk requirement from ==2.58.* to ==2.59.*
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.58.0a1...2.59.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:36:35 +02:00
dependabot[bot] 867512eee5 Bump picomatch
Bumps  and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:34:46 +02:00
Raphael Michel 1436b65347 Add webhooks for quota changes (Z#23232443) 2026-05-17 16:33:20 +02:00
sweenu cc06588991 Rephrase refund info paragraph 2026-05-17 16:33:15 +02:00
dependabot[bot] 32bd9fa265 Bump vite from 8.0.0 to 8.0.12
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.0 to 8.0.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.12
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:56 +02:00
dependabot[bot] bdc9b155f9 Bump flatted from 3.3.3 to 3.4.2
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:49 +02:00
dependabot[bot] 1af2941594 Bump postcss from 8.5.8 to 8.5.14
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.14.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.14)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:45 +02:00
dependabot[bot] 11dc1e6f70 Bump arabic-reshaper from 3.0.0 to 3.0.1
Bumps [arabic-reshaper](https://github.com/mpcabd/python-arabic-reshaper) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/mpcabd/python-arabic-reshaper/releases)
- [Commits](https://github.com/mpcabd/python-arabic-reshaper/compare/v3.0.0...v3.0.1)

---
updated-dependencies:
- dependency-name: arabic-reshaper
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:17:45 +02:00
Nikolai e08243e3b2 Translations: Update Danish
Currently translated at 59.1% (3725 of 6295 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
Yasunobu YesNo Kawaguchi 3a4e30f2ec Translations: Update Japanese
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
Yasunobu YesNo Kawaguchi ea2fa741f5 Translations: Update Japanese
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
Stefano Campus 20d1bb9d32 Translations: Update Italian
Currently translated at 40.0% (2521 of 6295 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
Hijiri Umemoto ad48d592e7 Translations: Update Japanese
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
pajowu 4861aca640 Fix timepicker in checkinrules (#6182)
The timepickers format was changed by accident to the datetimeformat in the vue3 migration
2026-05-12 16:17:24 +02:00
pajowu 82450c8250 Handle related fields in export_form_data (Z#23233538) (#6157) 2026-05-12 14:55:25 +02:00
Richard Schreiber b21b69b2b8 Fix playwright install on CI (#6180) 2026-05-12 13:14:05 +02:00
luelista 80ed6e76cd Fix failing orderlist export if orders with invalid payment provider identifiers exist (Z#23233440) (#6159)
* Fix orderlist export if orders with invalid payment provider identifiers exist (Z#23233440)
* Performance: Move _get_all_payment_methods out of loop
2026-05-12 11:57:18 +02:00
Lukas Bockstaller bb211be436 use datetime.fromisoformat instead of dateutil.parser (Z#23234093) (#6164)
* use datetime.fromisoformat instead of dateutil.parser

* convert remaining parser usages as well
2026-05-12 10:41:24 +02:00
Richard Schreiber 3b70ef8c84 Allow event being optional in LoggingMixin (#6166) 2026-05-12 09:45:42 +02:00
Richard Schreiber 9d57380c9a Widget: fix missing whitespace in PriceBox 2026-05-12 09:34:17 +02:00
Richard Schreiber 8b468c31a5 Fix translation for order import (#6165) 2026-05-12 09:03:34 +02:00
Richard Schreiber 9aec608601 Fix checkinrules js errors 2026-05-12 08:34:22 +02:00
Raphael Michel e542bb606d Vue3: Minor fixes in checkinlist editor 2026-05-11 18:51:21 +02:00
Raphael Michel fe1b4ec9d0 Order bulk action: Remove nonsensical <form action> attribute (#6154) 2026-05-11 17:39:46 +02:00
rash f04df7a6ee Migrate vue2 control components and widget to vue3 and vite (#5989)
* setup vite and integrate fully with django

- vite starts with `python manage.py runserver`
- add templatetags to simply load vite hmr and entry points
- add eslint (recheck rules)
- enable non-strict ts

* better syntax for cors header setting

* migrate checkin rules editor to vue3

- move constants to a module
- move reading from and writing to non-vue html to django interop module
- switch to composition api and script setup sfc with pug
- use optional chaining operators a lot to simplify code

* migrate webcheckin plugin to vite+vue3

- migrate vue sfcs to script setup and pug
- move fetch calls into a api.ts module
- move common formatting and i18n strings into module

* fix migration error

* first draft migrating widget to vue3/vite

* first couple widget e2e tests

courtesy of claude
most of the tests don't work yet

* test file is not actually used

* drop widget_ prefix from e2e test fixtures

* add test for complete widget journey for simple event

* switch timezone in e2e tests to Europe/Berlin

* make dates in e2e tests relative

* migrate widget bugfix #5886

* start testing event series widget

* working vite widget setup for prod (untested), local dev (with or without dev server) and pytests, with flags for running the old version or the vite version

* simplify e2e test iframe check

* less flaky e2e tests

* top level await in iife build mode is not supported, so let's do import.meta.glob instead (we just need the build step not to see await, the code doesn't actually ever get loaded because it's DEV only)

* fix inconsistencies from automatic migration

* Allow gradual rollout of new vite-based widget by adding urls to an allowlist that gets checked against the "Origin" http header of request fetching the widget js

* add e2e tests for widget button, testing empty cart, adding specific items, and subevents

* remove janky claude testts again

* resolve migration TODOs: properly refocus parent on navigations

* use `npm run dev:control` for the vite dev server for admin components

* upgrade npm dependencies

* fix js linter errors

* fix python linter errors

* build all control vue components

* add new js config files to check-manifest ignore

* working prod build

acutal serving of built assets not tested yet

* fix templatetag paths to match what's in the vite mantifest

* add missing quotes around 'unsafe-eval' cors value

* remove now unused old vue2 tooling

* try fixing e2e test ci

* fix flake8 error

* check if vite build artefacts are in the wheel

* add license headers

* remove dom manipilation code necessary for `div.pretix-widget-compat` to work. No longer needed for vue3

* remove superfluous `createElement` calls

They might have been there because of IE, which is no longer relevant

* make widget dev mode parametizable through query params and document the usage and those params

* fix rst syntax

* remove migration todos file

Co-authored-by: luelista <mira@teamwiki.de>

* rearrange dockerfile commands for smaller image, thanks @luelista

* Update .gitignore, adding .vite

Co-authored-by: luelista <mira@teamwiki.de>

* add eslint CI

* make vue dev work in plugins

* fix docker build

* rebuild vite setup to support static prod plugins and dynamic hmr plugin development

* use toml for vite plugin config instead of standalone json file

* Add widget changes from #6047, #6149

* Allow buttons to reuse cart (Z#23226853)

* Always keep cart of buttons with items set

* widget: handle cart if not same-site (#6149)

---------

Co-authored-by: luelista <mira@teamwiki.de>
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-05-11 15:05:06 +02:00
pajowu 1640ddd497 Widget: handle cart if not same-site (Z#23233393)
Sets SameSite for cookie if page is secure, so cookie can be read even if not same-site. Also stores cart-id in vue state, so correct cart is used even if cookies to not work
2026-05-11 15:02:57 +02:00
pajowu 27148324a6 sendmail: Add missing cleanup migration (#6158) 2026-05-11 14:53:47 +02:00
corentin-spec 71edfa8e1a Translations: Update French
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-08 09:48:04 +02:00
Daniel Musketa 8303ba7808 Translations: Update German
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-08 09:48:04 +02:00
pajowu 5bbbf0334d sendmail: Do not copy rules with subevent when closing an event (Z#23233683) (#6156) 2026-05-06 15:56:06 +02:00
sweenu 14708eef80 Invoice: fix issuer details rendering when address missing (#6139) 2026-05-05 17:58:02 +02:00
Ruud Hendrickx 952f121008 Translations: Update Dutch (Belgium)
Currently translated at 83.0% (5228 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Ruud Hendrickx 074d26cff3 Translations: Update Dutch
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Mie Frydensbjerg 6a9815ea5f Translations: Update Danish
Currently translated at 58.4% (3677 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel Musketa 01bd81a3cd Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Martin Gross 6ae8cfe6f0 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Martin Gross b60c8165c2 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel Musketa e460bf8bae Translations: Update Norwegian Bokmål
Currently translated at 83.3% (5247 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel Musketa b4f3d5c435 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel Musketa 4bc8caae73 Translations: Update German
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Paul Berschick 9183034c15 Translations: Update Spanish
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Paul Berschick 33ccd4342f Translations: Update Catalan
Currently translated at 29.7% (1875 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Paul Berschick 301c47b761 Translations: Update Spanish
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
CVZ-es b0d1c93fd9 Translations: Update Spanish
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
CVZ-es 70d59a960c Translations: Update French
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel Musketa e87b030427 Stripe: Add missing word in help text (#6146) 2026-05-05 17:15:34 +02:00
Daniel Musketa 994e4b410a Fix gettext singular forms in cart error_messages (#6147) 2026-05-05 17:15:14 +02:00
Daniel Musketa bd6abbc280 Docs: Update link to Django style guide (#6140) 2026-05-05 17:14:22 +02:00
dependabot[bot] ca7c982abd Bump @babel/preset-env from 7.29.0 to 7.29.3 in /src/pretix/static/npm_dir (#6136)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.29.0 to 7.29.3.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.3/packages/babel-preset-env)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 17:13:40 +02:00
Raphael Michel 6010d7f9e5 Order details: Link to subevent details (Z#23227664) (#6148) 2026-05-05 14:56:02 +02:00
Richard Schreiber ac08359a0e Widget: Raise BadRequest when GET-offset is not an int (#6143) 2026-05-04 16:19:02 +02:00
Raphael Michel 0aee73a9bd Quotas: Add bulk-edit, bulk-delete and filter form (#6080)
* Quotas: Add bulk-edit, bulk-delete and filter form

* Fix GroupConcat

* Apply suggestions from code review

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Review notes

* Fix handling of required fields

---------

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2026-05-04 12:44:22 +02:00
luelista 27183a26ee Respect per-event plugin availability in OrganizerPluginEvents view (#5983)
* Allow plugins to declare their availability per event

* Fix message type

* small optimization of PluginsField serializer
2026-05-04 11:34:05 +02:00
Thomas Göttgens 0acaed41be Fix Dockerfile syntax for chmod command (#6145) 2026-05-04 11:23:44 +02:00
Raphael Michel 993acce05a Settings: Fix typo in class path to mail backend (#6144) 2026-05-04 11:22:47 +02:00
luelista fe2132435c Fix permissions of /pretix in docker container (#6133) 2026-05-04 11:13:38 +02:00
Raphael Michel f4fcca19a4 Orders API: Fix race condition in voucher redemption (Z#23230391) (#6067)
The old code relied on the `Voucher.redeemed` value obtained *before*
the lock was taken, not afterwards.

The change in services/orders.py is functionally pointless, but it makes
the pattern of "fill availability only after lock" clearer and might
avoid introducing similar bugs in the future.
2026-04-29 19:57:08 +02:00
Raphael Michel 24d26a9455 Badges: Add export layout for 4x3" on letter (Z#23232464) (#6128)
* Badges: Add export layout for 4x3" on letter (Z#23232464)

* Consistent naming
2026-04-29 15:31:54 +02:00
Phin Wolkwitz 589f51454e Add locations to program times (Z#23221129)
Add location for program time slots and extend .ical and PDF placeholder
2026-04-29 11:59:06 +02:00
Raphael Michel bda27d72e7 Bump version to 2026.5.0.dev0 2026-04-28 16:48:33 +02:00
Raphael Michel f67690bc56 Bump version to 2025.5.0.dev0 2026-04-28 16:47:51 +02:00
Raphael Michel 75c8f97080 Bump version to 2026.4.0 2026-04-28 16:47:33 +02:00
Raphael Michel 10789f097d Bump version to 2025.5.0.dev0 2026-04-28 16:39:29 +02:00
Raphael Michel 1adec102e6 Bump version to 2025.4.0 2026-04-28 16:39:24 +02:00
Raphael Michel 921fd801e5 Thumbnails: Perform color space transform before resizing (Z#23232101) (#6120) 2026-04-28 15:35:27 +02:00
Kara Engelhardt 448d2e70d5 AddOnsStep: Expand selected variants 2026-04-28 14:50:35 +02:00
Kara Engelhardt 49f692c666 AddOnsStep: Use post data as initial data if exists (Z#23232311) 2026-04-28 14:50:35 +02:00
Kara Engelhardt 2d31c62812 Allow dash character in pdf placeholders 2026-04-28 13:10:08 +02:00
Raphael Michel 08df3d828d Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-04-28 12:20:37 +02:00
Raphael Michel 96e10bcd71 Translations: Update German
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-04-28 12:20:37 +02:00
Raphael Michel ff434f4384 Translations: Update wordlist 2026-04-28 12:17:11 +02:00
dependabot[bot] 653f83fc90 Update cryptography requirement from >=46.0.7 to >=47.0.0
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.7...47.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 47.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 11:22:54 +02:00
Raphael Michel d6fe29210a Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@pretix.eu>
2026-04-28 11:04:18 +02:00
Nikolai a13bb630d5 Translations: Update Danish
Currently translated at 58.4% (3676 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Nikolai fd0b3bac3c Translations: Update Danish
Currently translated at 58.9% (151 of 256 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Nikolai 7f6b5d7331 Translations: Update Danish
Currently translated at 56.3% (3542 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Nikolai 27398c08c7 Translations: Update Danish
Currently translated at 54.5% (3428 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Sandra Rial Pérez c061179f37 Translations: Update Galician
Currently translated at 19.1% (1207 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Sandra Rial Pérez bd90badc54 Translations: Update Galician
Currently translated at 19.0% (1196 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Nikolai 90800f219b Translations: Update Danish
Currently translated at 54.3% (3414 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Sandra Rial Pérez 4767cb38fc Translations: Update Galician
Currently translated at 17.6% (1111 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Nikolai 8fd366be76 Translations: Update Danish
Currently translated at 52.2% (3284 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Nikolai 75660600f4 Translations: Update Danish
Currently translated at 50.1% (3153 of 6287 strings)

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

powered by weblate
2026-04-28 11:03:23 +02:00
Richard Schreiber 217744a9f2 Remove unused download of all tickets of an order in pretix-control (#6086)
* Remove unused code for ticket download of whole order in pretix-control

* fix flake8

* fix isort
2026-04-28 09:45:35 +02:00
Richard Schreiber 1c7ce4b1ca Validate id for async tasks 2026-04-28 08:56:32 +02:00
Raphael Michel 8426a68760 Prevent nullbytes in input data globally (#6071)
* Prevent nullbytes in input data globally

* Only on urlencoded POST

* Split middleware
2026-04-27 17:28:36 +02:00
Raphael Michel 1157e2aeed Events/subevents: Fix missing logging for meta changes (Z#23232443) (#6110)
* Events/subevents: Fix missing logging for meta changes (Z#23232443)

* Fix logging
2026-04-27 15:12:21 +02:00
Raphael Michel 771f4f5d1e Turn attendee emails on by default for new events (Z#23213656) (#5598)
* Turn attendee emails on by default for new events (Z#23213656)

I think the thing that makes me most unhappy is that *most* organizers will
probably want to turn off mail_send_order_paid_attendee when they set
ticket_download_pending and I don't think organizers will remember that, but
it also seems complex and weird to create an automatism for it?

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

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

---------

Co-authored-by: Martin Gross <gross@rami.io>
2026-04-27 15:00:48 +02:00
Raphael Michel 496591bb3b Navigation: suggest event or organizer by domain (Z#23231404) (#6107) 2026-04-27 14:55:59 +02:00
Raphael Michel 88165c098e Subevents: Allow to skip conflicting dates in bulk-creation (Z#23217384) (#6079)
* Subevents: Allow to skip conflicting dates in bulk-creation

* Update src/pretix/control/templates/pretixcontrol/subevents/bulk.html

* Fix overlap calc for consecutive subevents

* Add test for skipping conflicting dates in bulk-creation

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-04-27 14:52:49 +02:00
dependabot[bot] 82a14a4f83 Update pytest-asyncio requirement from >=0.24 to >=1.3.0 (#6108)
Updates the requirements on [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest-asyncio/releases)
- [Commits](https://github.com/pytest-dev/pytest-asyncio/compare/v0.24.0...v1.3.0)

---
updated-dependencies:
- dependency-name: pytest-asyncio
  dependency-version: 1.3.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-04-27 12:39:36 +02:00
Kara Engelhardt ff77a2125a Limit widget frame inner height to 100dvh (Z#23231969)
Fixes a bug where the submit buttons were obscured by the browsers elements on some ios devices
2026-04-27 12:38:32 +02:00
Raphael Michel 97904d8567 Backend: Support are-you-sure for dynamically added form parts (Z#23232506) (#6109) 2026-04-27 12:24:55 +02:00
Raphael Michel a6a9eb6a6a Subevent selection: Order by date before name (Z#23231460) (#6111) 2026-04-27 12:23:17 +02:00
Raphael Michel b000dff134 Invoices: Allow to use currency rates from National Bank of Poland (#6100) 2026-04-21 15:14:10 +02:00
Kara Engelhardt ba75de7e7d Handle existing cart with empty session in presale views (PRETIXEU-D9Y) 2026-04-21 13:05:42 +02:00
Raphael Michel 35e1df28d9 Overhaul contribution guide & add a AI policy (#6038)
* Overhaul contribution guide & add a AI policy

* Fix broken links
2026-04-21 11:32:56 +02:00
Martin Gross 7e457f7430 Set max_length to 70 but for all name fields together and not only every single one. 2026-04-21 10:45:00 +02:00
Martin Gross 5faa85ed40 isort 2026-04-21 10:45:00 +02:00
Martin Gross 1b88a84a83 Move validation into form field. 2026-04-21 10:45:00 +02:00
Martin Gross 447cffa7a8 Customer Accounts: Limit length; reject URLs in name 2026-04-21 10:45:00 +02:00
dependabot[bot] 6d255bb9cc Update defusedcsv requirement from >=1.1.0 to >=3.0.0 (#6105)
Updates the requirements on [defusedcsv](https://github.com/raphaelm/defusedcsv) to permit the latest version.
- [Commits](https://github.com/raphaelm/defusedcsv/compare/v1.1.0...v3.0.0)

---
updated-dependencies:
- dependency-name: defusedcsv
  dependency-version: 3.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-04-20 23:50:50 +02:00
dependabot[bot] 4fe405886e Update tlds requirement from >=2026021400 to >=2026041800 (#6104)
Updates the requirements on [tlds](https://github.com/kichik/tlds) to permit the latest version.
- [Commits](https://github.com/kichik/tlds/commits)

---
updated-dependencies:
- dependency-name: tlds
  dependency-version: '2026041800'
  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-04-20 22:44:03 +02:00
Richard Schreiber b7d3e8a80a Add invoice numbers to paymentlist export (Z#23227966) (#6097) 2026-04-20 17:55:44 +02:00
Raphael Michel d0d76ffddc Delete unused code (#6026)
* Delete unused code

* Delete template
2026-04-20 16:56:50 +02:00
dependabot[bot] c04be5c0d9 Update cryptography requirement from >=44.0.0 to >=46.0.7 (#6084)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/44.0.0...46.0.7)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.7
  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-04-20 15:09:49 +02:00
dependabot[bot] ee1a8420a5 Update sentry-sdk requirement from ==2.57.* to ==2.58.* (#6095)
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.57.0...2.58.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.58.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-04-20 15:09:33 +02:00
dependabot[bot] d9000c2a66 Update tlds requirement from >=2020041600 to >=2026021400 (#6088) 2026-04-20 15:09:04 +02:00
Yasunobu YesNo Kawaguchi 4530d864d3 Translations: Update Japanese
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-20 10:09:50 +02:00
Yasunobu YesNo Kawaguchi b968266611 Translations: Update Japanese
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-20 10:09:50 +02:00
Yasunobu YesNo Kawaguchi 640518c1b3 Translations: Update Japanese
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-20 10:09:50 +02:00
Nikolai 0715144a31 Translations: Update Danish
Currently translated at 48.5% (3055 of 6287 strings)

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

powered by weblate
2026-04-20 10:09:50 +02:00
Tim 58ea7c8656 Translations: Update Spanish
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-20 10:09:50 +02:00
Tim a8fe6f505e Translations: Update Spanish
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-20 10:09:50 +02:00
Nikolai baeec92203 Translations: Update Danish
Currently translated at 47.7% (3004 of 6287 strings)

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

powered by weblate
2026-04-20 10:09:50 +02:00
Tim 2f9ac05184 Translations: Update Spanish
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-20 10:09:50 +02:00
Mie Frydensbjerg 4beea63b49 Translations: Update Danish
Currently translated at 46.5% (2925 of 6287 strings)

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

powered by weblate
2026-04-16 11:01:56 +02:00
Nikolai 5e49df0ef6 Translations: Update Danish
Currently translated at 46.1% (2904 of 6287 strings)

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

powered by weblate
2026-04-16 11:01:56 +02:00
pajowu b3bb9fccb5 Translations: Update Danish
Currently translated at 44.2% (2784 of 6287 strings)

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

powered by weblate
2026-04-16 11:01:56 +02:00
Lukas Bockstaller e3ffd66691 Giftcard/Reusable Media API: fix expand permission check (Z#23230608) (#6091)
* add failing tests

* add permission checks in to_representation

* only overwrite final representation not the serializer

* styling

* include review
2026-04-15 15:59:08 +02:00
Martin Gross 0f2ebb8687 PPv2: Fix permission-check for ISU (event.settings.general:write to event.settings.payment:write) 2026-04-14 17:02:47 +02:00
Richard Schreiber efd887b439 API: fix PDF-download name (Z#23231496) 2026-04-14 14:13:14 +02:00
pajowu 8690d65e99 Do not show payment text of canceled and failed payments on invoice (Z#23231070) (#6075) 2026-04-14 13:02:12 +02:00
Richard Schreiber 5682d3ed56 Do not force PDFs to be downloaded (Z#23225892) (#5994)
* Display invoice and tickets inline in browser (Z#23225892)

* Use FileResponse filename for AnswerDownload

* Use inline for PDF-view in pretix-control editor

* use as_attachment for API FileResponses

* do not ignore csp even for disposition=inline

* use as_attachment for file responses in control

* remove unused code

* improve code style

* Invoice preview inline

* do not force download on tickets in backend

* do not force download on AnswerDownload

* imrpove code style

* improve code style

* fix missing int str conversion

* Apply suggestions from code review

Co-authored-by: luelista <mira@teamwiki.de>

---------

Co-authored-by: luelista <mira@teamwiki.de>
2026-04-14 09:12:09 +02:00
pajowu 059ff6c99b Allow buttons to reuse cart (Z#23226853) (#6047)
* Allow buttons to reuse cart (Z#23226853)

* Always keep cart of buttons with items set
2026-04-13 19:32:33 +02:00
Mie Frydensbjerg f46fc7fa69 Translations: Update Danish
Currently translated at 44.2% (2784 of 6287 strings)

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

powered by weblate
2026-04-13 16:02:34 +02:00
pajowu 3473fa738d Fix AttributeError in CheckPrivateNetworkMixin (#6076) 2026-04-10 12:47:53 +02:00
Ruud Hendrickx 6c7163406e Translations: Update Dutch (Belgium)
Currently translated at 82.9% (5214 of 6287 strings)

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

powered by weblate
2026-04-10 11:47:38 +02:00
Hijiri Umemoto 49729d2c87 Translations: Update Japanese
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-10 11:47:38 +02:00
pajowu e80b4b560b customer login: open pw reset link in new tab (Z#23231027) (#6074)
This way customers don't have to break their checkout flow and the link works in a widgets iframe
2026-04-10 11:44:36 +02:00
pajowu 0bb04ca8f0 Email: Check custom SMTP IP at usage time 2026-04-10 10:57:08 +02:00
Raphael Michel f50548cd02 Fix crash on build 2026-04-10 10:34:15 +02:00
Raphael Michel bb450e1be9 Add default protection for SSRF 2026-04-10 10:34:15 +02:00
Kian Cross 6d07530d2b Waiting list: group product choices by category (#6006)
* Group waiting list product choices by category

Use optgroups to group products by category in the waiting list selection
dropdown.

Products are normally separated in the UI by category grouping, but this
context is lost in the waiting list form. When multiple products share the
same name, this can make it difficult for customers to distinguish between
them.

* Add tests for waiting list initial selection with optgroups

Verify that the initial product selection (via `?item=` and `?var=`
query parameters) works correctly when choices are grouped by category
into `<optgroup>`s. Covers both plain items and items with variations.
2026-04-10 09:14:34 +02:00
Kara Engelhardt 5d7ee584d9 Fix AttributeError when running tests with debug toolbar installed 2026-04-09 13:21:54 +02:00
Lukas Bockstaller 58cce4b85e adds fallback to PaymentDetailsField (PRETIXEU-D6V) (#6041)
* adds fallback to PaymentDetailsField

* return empty object instead of info_data
2026-04-09 12:32:46 +02:00
luelista aa420d4353 Do not reset event list type automatically (Z#23226325) (#6068)
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-04-08 18:47:45 +02:00
dependabot[bot] d2ca217cd8 Bump brace-expansion from 1.1.12 to 1.1.13 in /src/pretix/static/npm_dir (#6050)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-08 15:30:15 +02:00
dependabot[bot] cb6d3967a0 Bump picomatch from 2.3.1 to 2.3.2 in /src/pretix/static/npm_dir (#6030)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-08 15:30:05 +02:00
Raphael Michel 221cbd15ab [SECURITY] API: Add missing event filter for check-ins 2026-04-08 13:57:55 +02:00
Lukas Bockstaller 5c7104634e Order import: handle mixed endings of last line (Z#23230806) (#6066)
* handle mixed line endings in import

* formatting
2026-04-08 13:25:38 +02:00
337 changed files with 156605 additions and 149587 deletions
+1
View File
@@ -1,5 +1,6 @@
doc/
env/
node_modules/
res/
local/
.git/
+5
View File
@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
+4 -1
View File
@@ -46,4 +46,7 @@ jobs:
- name: Run build
run: python -m build
- name: Check files
run: unzip -l dist/pretix*whl | grep node_modules || exit 1
run: |
for pat in 'static.dist/vite/widget/widget.js' 'static.dist/vite/control/assets/checkinrules/main-' 'static.dist/vite/control/assets/webcheckin/main-'; do
unzip -l dist/pretix*whl | grep -q "$pat" || { echo "Missing: $pat"; exit 1; }
done
+43
View File
@@ -0,0 +1,43 @@
name: JS Code Style
on:
push:
branches: [ master ]
paths:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
pull_request:
branches: [ master ]
paths:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
permissions:
contents: read
env:
FORCE_COLOR: 1
jobs:
eslint:
name: eslint
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install Dependencies
run: npm ci
- name: Run ESLint
run: npm run lint:eslint
+44 -1
View File
@@ -72,7 +72,7 @@ jobs:
run: make all compress
- name: Run tests
working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --maxfail=100
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --ignore=tests/e2e --maxfail=100
- name: Run concurrency tests
working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db
@@ -84,3 +84,46 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
if: matrix.database == 'postgres' && matrix.python-version == '3.13'
e2e:
runs-on: ubuntu-22.04
name: E2E Tests
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pretix
options: >-
--health-cmd "pg_isready -U postgres -d pretix"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install -y gettext
- name: Install Python dependencies
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary
- name: Install JS dependencies
working-directory: ./src
run: make npminstall
- name: Compile
working-directory: ./src
run: make all compress
- name: Install Playwright browsers
run: playwright install
- name: Run E2E tests
working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10
+2
View File
@@ -24,5 +24,7 @@ local/
.project
.pydevproject
.DS_Store
node_modules/
.vite/
+3 -2
View File
@@ -10,9 +10,10 @@ tests:
- cd src
- python manage.py check
- make all compress
- playwright install
- PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --maxfail=100
except:
- pypi
- '/^v.*$/'
pypi:
stage: release
image:
@@ -35,7 +36,7 @@ pypi:
- twine check dist/*
- twine upload dist/*
only:
- pypi
- '/^v.*$/'
artifacts:
paths:
- src/dist/
+1 -1
View File
@@ -1 +1 @@
17
24
+1
View File
@@ -0,0 +1 @@
/*
+10 -5
View File
@@ -1,11 +1,16 @@
Contributing to pretix
======================
Hey there and welcome to pretix!
Welcome to pretix, we are happy that you would like to contribute.
Before you do so, please make sure to read the following documents:
* We've got a contributors guide in [our documentation](https://docs.pretix.eu/dev/development/contribution/) together with notes on the [development setup](https://docs.pretix.eu/dev/development/setup.html).
- [Contribution workflow](https://docs.pretix.eu/dev/development/contribution/general.html)
- [AI-assisted contribution policy](https://docs.pretix.eu/dev/development/contribution/ai.html)
- [Coding style and quality](https://docs.pretix.eu/dev/development/contribution/style.html)
- [Development setup](https://docs.pretix.eu/dev/development/setup.html)
- [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html)
* Please note that we have a [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html) in place that applies to all project contributions, including issues, pull requests, etc.
* Before we can accept a PR from you we'll need you to sign [our CLA](https://pretix.eu/about/en/cla). You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
Before we can accept your first PR we'll need you to sign [our **Contributor License Agreement** (CLA)](https://pretix.eu/about/en/cla).
You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
**Before contributing new functionality, always open a discussion first.**
+8 -3
View File
@@ -1,6 +1,7 @@
FROM python:3.13-trixie
RUN apt-get update && \
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
gettext \
@@ -21,8 +22,7 @@ RUN apt-get update && \
libmaxminddb0 \
libmaxminddb-dev \
zlib1g-dev \
nodejs \
npm && \
nodejs && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
dpkg-reconfigure locales && \
@@ -31,6 +31,7 @@ RUN apt-get update && \
mkdir /etc/pretix && \
mkdir /data && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
chmod 0755 /pretix && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static && \
mkdir /etc/supervisord
@@ -49,6 +50,10 @@ COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
COPY pyproject.toml /pretix/pyproject.toml
COPY _build /pretix/_build
COPY src /pretix/src
COPY package.json /pretix/package.json
COPY package-lock.json /pretix/package-lock.json
COPY tsconfig.json /pretix/tsconfig.json
COPY vite.config.ts /pretix/vite.config.ts
RUN pip3 install -U \
pip \
+5
View File
@@ -48,3 +48,8 @@ recursive-include src Makefile
recursive-exclude doc *
recursive-exclude deployment *
recursive-exclude res *
include package.json
include package-lock.json
include tsconfig.json
include vite.config.ts
+184
View File
@@ -844,3 +844,187 @@ You can also fetch existing leads (if you are authorized to do so):
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
Retrieving Vouchers
"""""""""""""""""""
Vouchers returned by the App API use a different format than described in :ref:`rest-vouchers`.
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the voucher
code string The voucher code that is required to redeem the voucher
max_usages integer The maximum number of times this voucher can be
redeemed (default: 1).
redeemed integer The number of times this voucher already has been
redeemed.
valid_until datetime The voucher expiration date (or ``null``).
subevent string Name of the date inside an event series this voucher belongs to (or ``null``).
tag string A string that is used for grouping vouchers
comment string An internal exhibitor comment on the voucher.
items list of strings A list of items this voucher is restricted to (or ``null``).
price_mode string Determines how this voucher affects product prices.
Possible values:
* ``none`` No effect on price
* ``set`` The product price is set to the given ``value``
* ``subtract`` The product price is determined by the original price *minus* the given ``value``
* ``percent`` The product price is determined by the original price reduced by the percentage given in ``value``
value decimal (string) The value (see ``price_mode``)
redemptions list of objects A list of objects, where each object represents an order position that has been purchased using the voucher.
Each entry will contains the fields ``attendee_fields``, ``redemption_date`` and ``subevent``.
The attendee data in the ``attendee_fields`` that is shown is based on the event's configuration, and each entry
contains the fields ``id``, ``label``, ``value``, and ``details``. ``details`` is usually empty
except in a few cases where it contains an additional list of objects
with ``value`` and ``label`` keys (e.g. splitting of names).
===================================== ========================== =======================================================
.. http:get:: /exhibitors/api/v1/vouchers/
Returns a list of all vouchers connected to the exhibitor.
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
The app should dynamically show these values (read-only) with the labels sent by the server.
**Example request**:
.. sourcecode:: http
GET /exhibitors/api/v1/vouchers/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"subevent": null,
"tag": "testvoucher",
"comment": "",
"items": [
"All"
],
"price_mode": "set",
"value": "12.00",
"redemptions": [
{
"attendee_fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
],
"redemption_date": "2026-05-06",
"subevent": null
},
]
}
]
}
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
.. http:get:: /exhibitors/api/v1/vouchers/(id)/
Returns the details of a single, specific voucher connected to the exhibitor.
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
The app should dynamically show these values (read-only) with the labels sent by the server.
**Example request**:
.. sourcecode:: http
GET /exhibitors/api/v1/vouchers/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"subevent": null,
"tag": "testvoucher",
"comment": "",
"items": [
"All"
],
"price_mode": "set",
"value": "12.00",
"redemptions": [
{
"attendee_fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
],
"redemption_date": "2026-05-06",
"subevent": null
},
]
}
:param id: The ``id`` field of the voucher to fetch
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
:statuscode 404: Voucher not found in system
+13 -6
View File
@@ -16,6 +16,7 @@ Field Type Description
id integer Internal ID of the program time
start datetime The start date time for this program time slot.
end datetime The end date time for this program time slot.
location multi-lingual string The program time slot's location (or ``null``)
===================================== ========================== =======================================================
.. versionchanged:: TODO
@@ -54,17 +55,20 @@ Endpoints
{
"id": 2,
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
"end": "2025-08-15T00:00:00Z",
"location": null
},
{
"id": 3,
"start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z"
"end": "2025-08-13T22:00:00Z",
"location": null
},
{
"id": 14,
"start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z"
"end": "2025-08-17T22:00:00Z",
"location": null
}
]
}
@@ -99,7 +103,8 @@ Endpoints
{
"id": 1,
"start": "2025-08-15T22:00:00Z",
"end": "2025-10-27T23:00:00Z"
"end": "2025-10-27T23:00:00Z",
"location": null
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -125,7 +130,8 @@ Endpoints
{
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z"
"end": "2025-08-15T22:00:00Z",
"location": null
}
**Example response**:
@@ -139,7 +145,8 @@ Endpoints
{
"id": 17,
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z"
"end": "2025-08-15T22:00:00Z",
"location": null
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
+1
View File
@@ -70,6 +70,7 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.subevent.changed``
* ``pretix.subevent.deleted``
* ``pretix.event.item.*``
* ``pretix.event.quota.*``
* ``pretix.event.live.activated``
* ``pretix.event.live.deactivated``
* ``pretix.event.testmode.activated``
+24
View File
@@ -0,0 +1,24 @@
.. _`aipolicy`:
AI-assisted contribution policy
===============================
pretix is maintained by humans.
Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines, too).
We ask you to respect the time and effort put in by these humans by not sending low-effort, unqualified work, since it puts the burden of validation on the maintainer.
Therefore, the pretix project has strict rules for AI usage:
- **All AI usage in any form must be disclosed.** You must state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted.
- **The human-in-the-loop must fully understand all code.** If you can't explain what your changes do and how they interact with the greater system without the aid of AI tools, do not contribute to this project.
- **Issues and discussions can use AI assistance but must have a full human-in-the-loop.** This means that any content generated with AI must have been reviewed and edited by a human before submission. AI is very good at being overly verbose and including noise that distracts from the main point. Humans must do their research and trim this down.
- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the only acceptable AI-generated content, per the other rules in this policy.
- **Bad AI drivers will be excluded from the project.** People who produce bad contributions that are clearly AI (slop) will be blocked from our organization without warning.
This policy was inspired by the `ghostty project`_.
.. _ghostty project: https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md
+30 -11
View File
@@ -1,23 +1,39 @@
General remarks
===============
Contribution workflow
=====================
You are interested in contributing to pretix? That is awesome!
If youre new to contributing to open source software, dont be afraid. Well happily review your code and give you
constructive and friendly feedback on your changes.
constructive and friendly feedback on your changes. Every contribution should go through the following steps.
First of all, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
Discussion & Design
-------------------
pretix is a large and mature project with more of a decade of history and hopefully many more decades to come.
Keeping pretix in good shape over long timeframes is first and foremost a fight against complexity.
With every additional feature, complexity grows, and both features and complexity are hard to remove.
Even if you are doing the initial work of the contribution, accepting the contribution is not free for us.
Not only will we need to maintain the feature, but every feature adds cost to the maintenance of every other feature it interacts with, and every feature adds effort for users to understand how pretix works.
Therefore, we must carefully select what features we add, based on how well they fit the system in general and of how much use they will be to our larger user base.
We strongly ask you to **create a discussion on GitHub for every new feature idea** outlining the use case and the proposed implementation design.
Pull requests without prior discussion will likely just be closed.
For bug fixes and very minor changes, you can skip this step and open a PR right away.
Development
-----------
To develop your contribution, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
If you run into any problems on your way, please do not hesitate to ask us anytime!
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
assured that we will not tolerate any form of harassment.
While developing, please have a look at our :ref:`aipolicy` and our guidelines on :ref:`codestyle`.
Sending a patch
---------------
If you improved pretix in any way, we'd be very happy if you contribute it
back to the main code base! The easiest way to do so is to `create a pull request`_
on our `GitHub repository`_.
Once you have a first draft of your changes, please `create a pull request`_ on our `GitHub repository`_.
We recommend that you create a feature branch for every issue you work on so the changes can
be reviewed individually.
@@ -25,14 +41,17 @@ Please use the test suite to check whether your changes break any existing featu
the code style checks to confirm you are consistent with pretix's coding style. You'll
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
We automatically run the tests and the code style check on every pull request on Travis CI and we wont
We automatically run the tests and the code style check on every pull request through GitHub Actions and we wont
accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing,
just send the pull request and tell us we'll be glad to help.
If you add a new feature, please include appropriate documentation into your patch. If you fix a bug,
please include a regression test, i.e. a test that fails without your changes and passes after applying your changes.
Again: If you get stuck, do not hesitate to contact any of us, or Raphael personally at mail@raphaelmichel.de.
Again: If you get stuck, do not hesitate to contact us through GitHub discussions.
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
assured that we will not tolerate any form of harassment.
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
.. _GitHub repository: https://github.com/pretix/pretix
+1
View File
@@ -6,4 +6,5 @@ Contributing to pretix
general
style
ai
codeofconduct
+4 -4
View File
@@ -1,5 +1,7 @@
.. spelling:word-list:: Rebase rebasing
.. _`codestyle`:
Coding style and quality
========================
@@ -28,8 +30,6 @@ Code
Commits and Pull Requests
-------------------------
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
@@ -86,7 +86,7 @@ individual commits, we use "Rebase and merge" instead. Merge commits should be a
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/
.. _flake8: https://pypi.python.org/pypi/flake8
.. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/
.. _translation: https://docs.djangoproject.com/en/1.11/topics/i18n/translation/
.. _class-based views: https://docs.djangoproject.com/en/1.11/topics/class-based-views/
.. _translation: https://docs.djangoproject.com/en/6.0/topics/i18n/translation/
.. _class-based views: https://docs.djangoproject.com/en/6.0/topics/class-based-views/
.. _pytest-style: https://docs.pytest.org/en/latest/assert.html
.. _fixtures: https://docs.pytest.org/en/latest/fixture.html
+50
View File
@@ -110,6 +110,56 @@ process::
However, beware that code changes will not auto-reload within Celery.
Running the local development server will also automatically start a vite dev server for all control vue components.
Run the widget development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To locally develop the presale widget you need to start a separate vite dev server using::
npm run dev:widget
You can control the org, event and much more via query parameters like this::
http://localhost:5180/?org=testorg&event=testevent
The following query parameters are supported:
.. list-table::
:header-rows: 1
:widths: 20 20 60
* - Parameter
- Default
- Description
* - ``org``
- ``testorg``
- Organization slug
* - ``event``
- ``testevent``
- Event slug
* - ``host``
- ``http://localhost:8000``
- Backend host URL
* - ``type``
- ``widget``
- Element type: ``widget`` or ``button``
* - ``mode``
- ``dev``
- ``dev`` loads the Vite dev source, ``prod`` loads the built ``v2.{lang}.js``
* - ``lang``
- ``de``
- Language code for the prod script
* - ``button-text``
- ``Buy tickets!``
- Text content for the button (only used when ``type=button``)
Any other query parameter is passed through as an attribute on the widget/button element.
For example, ``?skip-ssl-check&list-type=calendar&items=123`` adds those attributes directly.
.. _`checksandtests`:
Code checks and unit tests
+108
View File
@@ -0,0 +1,108 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import ts from 'typescript-eslint'
import stylistic from '@stylistic/eslint-plugin'
import vue from 'eslint-plugin-vue'
import vuePug from 'eslint-plugin-vue-pug'
const ignores = globalIgnores([
'**/node_modules',
'**/dist'
])
export default defineConfig([
ignores,
...ts.config(
js.configs.recommended,
ts.configs.recommended
),
stylistic.configs.customize({
indent: 'tab',
braceStyle: '1tbs',
quoteProps: 'as-needed'
}),
...vue.configs['flat/recommended'],
...vuePug.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
localStorage: false,
$: 'readonly',
$$: 'readonly',
$ref: 'readonly',
$computed: 'readonly',
},
parserOptions: {
parser: '@typescript-eslint/parser'
}
},
rules: {
'no-debugger': 'off',
curly: 0,
'no-return-assign': 0,
'no-console': 'off',
'vue/require-default-prop': 0,
'vue/require-v-for-key': 0,
'vue/valid-v-for': 'warn',
'vue/no-reserved-keys': 0,
'vue/no-setup-props-destructure': 0,
'vue/multi-word-component-names': 0,
'vue/max-attributes-per-line': 0,
'vue/attribute-hyphenation': ['warn', 'never'],
'vue/v-on-event-hyphenation': ['warn', 'never'],
'import/first': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-explicit-any': 0,
'no-use-before-define': 'off',
'no-var': 'error',
'@typescript-eslint/no-use-before-define': ['error', {
typedefs: false,
functions: false,
}],
'@typescript-eslint/no-unused-vars': ['error', {
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}],
'@stylistic/comma-dangle': 0,
'@stylistic/space-before-function-paren': ['error', 'always'],
'@stylistic/max-statements-per-line': ['error', { max: 1, ignoredNodes: ['BreakStatement'] }],
'@stylistic/member-delimiter-style': 0,
'@stylistic/arrow-parens': 0,
'@stylistic/generator-star-spacing': 0,
'@stylistic/yield-star-spacing': ['error', 'after'],
},
},
{
files: [
'src/pretix/static/pretixcontrol/js/ui/checkinrules/**/*.vue',
'src/pretix/plugins/webcheckin/**/*.vue',
],
languageOptions: {
globals: {
moment: 'readonly',
},
},
},
{
files: [
'src/pretix/static/pretixpresale/widget/**/*.{ts,vue}',
],
languageOptions: {
globals: {
LANG: 'readonly',
},
},
},
])
+4781
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "pretix",
"version": "1.0.0",
"description": "",
"homepage": "https://github.com/pretix/pretix#readme",
"bugs": {
"url": "https://github.com/pretix/pretix/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/pretix/pretix.git"
},
"license": "SEE LICENSE IN LICENSE",
"author": "",
"type": "module",
"main": "index.js",
"directories": {
"doc": "doc"
},
"scripts": {
"dev:control": "vite",
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
"build": "npm run build:control -s && npm run build:widget -s",
"build:control": "vite build",
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/jquery": "^3.5.33",
"@types/moment": "^2.11.29",
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/language-plugin-pug": "^3.2.5",
"eslint": "^10.0.3",
"eslint-plugin-vue": "^10.8.0",
"eslint-plugin-vue-pug": "^1.0.0-alpha.5",
"globals": "^17.4.0",
"pug": "^3.0.3",
"sass-embedded": "^1.98.0",
"smol-toml": "^1.6.1",
"stylus": "^0.64.0",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.0"
}
}
+14 -12
View File
@@ -27,15 +27,15 @@ classifiers = [
]
dependencies = [
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
"babel",
"BeautifulSoup4==4.14.*",
"bleach==6.3.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=44.0.0",
"cryptography>=48.0.0",
"css-inline==0.20.*",
"defusedcsv>=1.1.0",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*",
"django-bootstrap3==26.1",
@@ -43,7 +43,7 @@ dependencies = [
"django-countries==8.2.*",
"django-filter==25.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.5.1",
"django-formtools==2.6.1",
"django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*",
"django-i18nfield==1.11.*",
@@ -56,7 +56,7 @@ dependencies = [
"django-redis==6.0.*",
"django-scopes==2.0.*",
"django-statici18n==2.7.*",
"djangorestframework==3.16.*",
"djangorestframework==3.17.*",
"dnspython==2.8.*",
"drf_ujson2==1.7.*",
"geoip2==5.*",
@@ -74,11 +74,11 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.12.*",
"PyJWT==2.13.*",
"phonenumberslite==9.0.*",
"Pillow==12.2.*",
"pretix-plugin-build",
"protobuf==7.34.*",
"protobuf==7.35.*",
"psycopg2-binary",
"pycountry",
"pycparser==3.0",
@@ -91,13 +91,13 @@ dependencies = [
"pyuca",
"qrcode==8.2",
"redis==7.4.*",
"reportlab==4.4.*",
"reportlab==4.5.*",
"requests==2.32.*",
"sentry-sdk==2.57.*",
"sentry-sdk==2.60.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
"tlds>=2020041600",
"tlds>=2026041800",
"tqdm==4.*",
"ua-parser==1.0.*",
"vobject==0.9.*",
@@ -111,20 +111,22 @@ dev = [
"aiohttp==3.13.*",
"coverage",
"coveralls",
"fakeredis==2.34.*",
"fakeredis==2.35.*",
"flake8==7.3.*",
"freezegun",
"isort==8.0.*",
"pep8-naming==0.15.*",
"potypo",
"pytest-asyncio>=0.24",
"pytest-asyncio>=1.4.0",
"pytest-cache",
"pytest-cov",
"pytest-django==4.*",
"pytest-mock==3.15.*",
"pytest-sugar",
"pytest-xdist==3.8.*",
"pytest-playwright",
"pytest==9.0.*",
"playwright",
"responses",
]
+5
View File
@@ -37,4 +37,9 @@ ignore =
CONTRIBUTING.md
Dockerfile
SECURITY.md
eslint.config.mjs
package-lock.json
package.json
tsconfig.json
vite.config.js
+6 -6
View File
@@ -9,10 +9,10 @@ localegen:
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
staticfiles: jsi18n
staticfiles: npminstall npmbuild jsi18n
./manage.py collectstatic --noinput
compress: npminstall
compress:
./manage.py compress
jsi18n: localecompile
@@ -25,8 +25,8 @@ coverage:
coverage run -m py.test
npminstall:
# keep this in sync with pretix/_build.py!
mkdir -p pretix/static.dist/node_prefix/
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
npm ci --prefix=pretix/static.dist/node_prefix
npm ci
npmbuild:
npm run build
+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__ = "2026.4.0.dev0"
__version__ = "2026.5.2"
+3 -2
View File
@@ -37,9 +37,11 @@ INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
# pretix needs to go before staticfiles
# so we can override the runserver command
'pretix.base',
'django.contrib.staticfiles',
'pretix.control',
'pretix.presale',
'pretix.multidomain',
@@ -243,7 +245,6 @@ STORAGES = {
COMPRESS_PRECOMPILERS = (
('text/x-scss', 'django_libsass.SassCompiler'),
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
)
COMPRESS_OFFLINE_CONTEXT = {
+7 -6
View File
@@ -21,13 +21,13 @@
#
import os
import shutil
import subprocess
from setuptools.command.build import build
from setuptools.command.build_ext import build_ext
here = os.path.abspath(os.path.dirname(__file__))
project_root = os.path.abspath(os.path.join(here, '..', '..'))
npm_installed = False
@@ -35,14 +35,14 @@ def npm_install():
global npm_installed
if not npm_installed:
# keep this in sync with Makefile!
node_prefix = os.path.join(here, 'static.dist', 'node_prefix')
os.makedirs(node_prefix, exist_ok=True)
shutil.copytree(os.path.join(here, 'static', 'npm_dir'), node_prefix, dirs_exist_ok=True)
subprocess.check_call('npm ci', shell=True, cwd=node_prefix)
subprocess.check_call('npm ci', shell=True, cwd=project_root)
npm_installed = True
def npm_build():
subprocess.check_call('npm run build', shell=True, cwd=project_root)
class CustomBuild(build):
def run(self):
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
@@ -62,6 +62,7 @@ class CustomBuild(build):
settings.COMPRESS_OFFLINE = True
npm_install()
npm_build()
management.call_command('compilemessages', verbosity=1)
management.call_command('compilejsi18n', verbosity=1)
management.call_command('collectstatic', verbosity=1, interactive=False)
+2
View File
@@ -47,3 +47,5 @@ HAS_MEMCACHED = False
HAS_CELERY = False
HAS_GEOIP = False
SENTRY_ENABLED = False
VITE_DEV_MODE = False
VITE_IGNORE = False
+26 -20
View File
@@ -133,37 +133,43 @@ class JobRunSerializer(serializers.Serializer):
return not bool(self._errors)
class ExportFormDataField(serializers.Field):
def get_attribute(self, instance):
return (instance.export_identifier, instance.export_form_data)
def to_representation(self, value):
export_identifier, export_form_data = value
exporter = self.context['exporters'].get(export_identifier)
if exporter:
return JobRunSerializer(exporter=exporter).to_representation(export_form_data)
else:
return export_form_data
def get_value(self, dictionary):
return dictionary
def to_internal_value(self, data):
if "export_form_data" in data:
identifier = data.get('export_identifier', self.parent.instance.export_identifier if self.parent.instance else None)
exporter = self.context['exporters'].get(identifier)
if exporter:
return JobRunSerializer(exporter=exporter).to_internal_value(data["export_form_data"])
else:
return data['export_form_data']
class ScheduledExportSerializer(serializers.ModelSerializer):
schedule_next_run = serializers.DateTimeField(read_only=True)
export_identifier = serializers.ChoiceField(choices=[])
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
owner = serializers.SlugRelatedField(slug_field='email', read_only=True)
error_counter = serializers.IntegerField(read_only=True)
export_form_data = ExportFormDataField()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']]
def validate(self, attrs):
if attrs.get("export_form_data"):
identifier = attrs.get('export_identifier', self.instance.export_identifier if self.instance else None)
exporter = self.context['exporters'].get(identifier)
if exporter:
try:
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
except ValidationError as e:
raise ValidationError({"export_form_data": e.detail})
else:
raise ValidationError({"export_identifier": ["Unknown exporter."]})
return attrs
def to_representation(self, instance):
repr = super().to_representation(instance)
exporter = self.context['exporters'].get(instance.export_identifier)
if exporter:
repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"])
return repr
def validate_mail_additional_recipients(self, value):
d = value.replace(' ', '')
if len(d.split(',')) > 25:
+2 -2
View File
@@ -115,10 +115,10 @@ class PluginsField(serializers.Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
active_plugins = set(obj.get_plugins())
return sorted([
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in active_plugins
])
def to_internal_value(self, data):
+6
View File
@@ -45,6 +45,12 @@ class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
return value
return super().to_representation(value)
def to_internal_value(self, data):
value = super().to_internal_value(data)
if value is not None:
return value.pk
return value
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):
+2 -2
View File
@@ -191,7 +191,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('start', 'end')
fields = ('start', 'end', 'location')
class ItemBundleSerializer(serializers.ModelSerializer):
@@ -222,7 +222,7 @@ class ItemBundleSerializer(serializers.ModelSerializer):
class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('id', 'start', 'end')
fields = ('id', 'start', 'end', 'location')
def validate(self, data):
data = super().validate(data)
+25 -3
View File
@@ -31,7 +31,9 @@ from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.organizer import (
CustomerSerializer, GiftCardSerializer,
)
from pretix.base.models import Order, OrderPosition, ReusableMedium
from pretix.base.models import (
Device, Order, OrderPosition, ReusableMedium, TeamAPIToken,
)
logger = logging.getLogger(__name__)
@@ -80,8 +82,7 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
)
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
# No additional permission check performed, documented limitation of the permission system
# Would get to complex/unusable otherwise since the permission depends on the event
# Permission Check performed in to_representation
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
else:
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
@@ -117,6 +118,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
)
return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
# late permission evaluations for checks that depend on the actual linked events
expand_nested = self.context['request'].query_params.getlist('expand')
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if 'linked_orderposition' in expand_nested:
if instance.linked_orderposition is not None:
event = instance.linked_orderposition.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
if 'linked_giftcard.owner_ticket' in expand_nested:
gc = instance.linked_giftcard
if gc is not None and gc.owner_ticket is not None:
event = gc.owner_ticket.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['linked_giftcard']['owner_ticket'] = {'id': instance.linked_giftcard.owner_ticket.id}
return r
class Meta:
model = ReusableMedium
fields = (
+13 -6
View File
@@ -769,7 +769,11 @@ class PaymentDetailsField(serializers.Field):
pp = value.payment_provider
if not pp:
return {}
return pp.api_payment_details(value)
try:
return pp.api_payment_details(value)
except Exception:
logger.exception("Failed to retrieve payment_details")
return {}
class OrderPaymentSerializer(I18nAwareModelSerializer):
@@ -1412,6 +1416,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
qa = QuotaAvailability()
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
qa.compute()
v_avail = {}
# These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
# use further down
@@ -1441,11 +1446,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
voucher_usage[v] += 1
if voucher_usage[v] > 0:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp.pk for cp in delete_cps])
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail < voucher_usage[v]:
if v not in v_avail:
v.refresh_from_db(fields=['redeemed'])
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=v) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp.pk for cp in delete_cps])
v_avail[v] = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail[v] < voucher_usage[v]:
errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.'
]
+13
View File
@@ -286,6 +286,19 @@ class GiftCardSerializer(I18nAwareModelSerializer):
)
return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
# late permission evaluations for checks that depend on the actual linked events
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
owner_ticket = instance.owner_ticket
if owner_ticket:
event = owner_ticket.order.event
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['owner_ticket'] = {'id': instance.owner_ticket.id}
return r
class Meta:
model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',
+1 -1
View File
@@ -1122,7 +1122,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
permission = 'event.orders:read'
def get_queryset(self):
qs = Checkin.all.filter().select_related(
qs = Checkin.all.filter(list__event=self.request.event).select_related(
"position",
"device",
)
+43 -28
View File
@@ -381,12 +381,15 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
return FileResponse(
ct.file.file,
filename='{}-{}-{}{}'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
)
return resp
@action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs):
@@ -1303,14 +1306,17 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
raise NotFound()
ftype, ignored = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
return FileResponse(
answer.file,
filename='{}-{}-{}-{}'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
),
as_attachment=True,
content_type=ftype or 'application/binary'
)
return resp
@action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
def printlog(self, request, **kwargs):
@@ -1365,15 +1371,18 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
if hasattr(image_file, 'seek'):
image_file.seek(0)
resp = FileResponse(image_file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
return FileResponse(
image_file,
filename='{}-{}-{}-{}.{}'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
),
as_attachment=True,
content_type=ftype or 'application/binary'
)
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
@@ -1399,12 +1408,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
return FileResponse(
ct.file.file,
filename='{}-{}-{}-{}{}'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
)
return resp
@action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs):
@@ -1986,9 +1998,12 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
if not invoice.file:
raise RetryException()
resp = FileResponse(invoice.file.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
return FileResponse(
invoice.file.file,
filename='{}.pdf'.format(invoice.number),
as_attachment=True,
content_type='application/pdf'
)
@action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs):
+6
View File
@@ -408,6 +408,12 @@ def register_default_webhook_events(sender, **kwargs):
_('This includes product added or deleted and changes to nested objects like '
'variations or bundles.'),
),
ParametrizedItemWebhookEvent(
'pretix.event.quota.*',
_('Quota changed'),
_('This includes related events like creation, deletion, opening or closing of quotas. '
'No webhook is sent for changes to the resulting availability.'),
),
ParametrizedEventWebhookEvent(
'pretix.event.live.activated',
_('Shop taken live'),
+80
View File
@@ -19,7 +19,10 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import ipaddress
import logging
import smtplib
import socket
from itertools import groupby
from smtplib import SMTPResponseException
from typing import TypeVar
@@ -237,3 +240,80 @@ def base_renderers(sender, **kwargs):
def get_email_context(**kwargs):
return PlaceholderContext(**kwargs).render_all()
def create_connection(address, timeout=socket.getdefaulttimeout(),
source_address=None, *, all_errors=False):
# Taken from the python stdlib, extended with a check for local ips
host, port = address
exceptions = []
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
af, socktype, proto, canonname, sa = res
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
if ip_addr.is_multicast:
raise socket.error(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise socket.error(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise socket.error(f"Request to private address {sa[0]} blocked")
sock = None
try:
sock = socket.socket(af, socktype, proto)
if timeout is not socket.getdefaulttimeout():
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sa)
# Break explicitly a reference cycle
exceptions.clear()
return sock
except socket.error as exc:
if not all_errors:
exceptions.clear() # raise only the last error
exceptions.append(exc)
if sock is not None:
sock.close()
if len(exceptions):
try:
if not all_errors:
raise exceptions[0]
raise ExceptionGroup("create_connection failed", exceptions)
finally:
# Break explicitly a reference cycle
exceptions.clear()
else:
raise socket.error("getaddrinfo returns an empty list")
class CheckPrivateNetworkMixin:
# _get_socket taken 1:1 from smtplib, just with a call to our own create_connection
def _get_socket(self, host, port, timeout):
# This makes it simpler for SMTP_SSL to use the SMTP connect code
# and just alter the socket connection bit.
if timeout is not None and not timeout:
raise ValueError('Non-blocking socket (timeout=0) is not supported')
if self.debuglevel > 0:
self._print_debug('connect: to', (host, port), self.source_address)
return create_connection((host, port), timeout, self.source_address)
class SMTP(CheckPrivateNetworkMixin, smtplib.SMTP):
pass
# SMTP used here instead of mixin, because smtp.SMTP_SSL._get_socket calls super()._get_socket and then wraps this socket
# super()._get_socket needs to be our version from the mixin
class SMTP_SSL(smtplib.SMTP_SSL, SMTP): # noqa: N801
pass
class CheckPrivateNetworkSmtpBackend(EmailBackend):
@property
def connection_class(self):
return SMTP_SSL if self.use_ssl else SMTP
+17 -2
View File
@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
def _get_all_payment_methods(self, qs):
pps = dict(get_all_payment_providers())
return sorted([(pp, pps[pp]) for pp in set(
return sorted([(pp, pps.get(pp, pp)) for pp in set(
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
'provider', flat=True
).distinct()
@@ -330,6 +330,7 @@ class OrderListExporter(MultiSheetListExporter):
taxsum=Sum('tax_value'), grosssum=Sum('value')
)
}
payment_methods = None
if form_data.get('include_payment_amounts'):
payment_sum_cache = {
(o['order__id'], o['provider']): o['grosssum'] for o in
@@ -347,6 +348,7 @@ class OrderListExporter(MultiSheetListExporter):
grosssum=Sum('amount')
)
}
payment_methods = self._get_all_payment_methods(qs)
sum_cache = {
(o['order__id'], o['tax_rate']): o for o in
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
@@ -434,7 +436,6 @@ class OrderListExporter(MultiSheetListExporter):
)
if form_data.get('include_payment_amounts'):
payment_methods = self._get_all_payment_methods(qs)
for id, vn in payment_methods:
row.append(
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
@@ -1103,13 +1104,25 @@ class PaymentListExporter(ListExporter):
def iterate_list(self, form_data):
provider_names = dict(get_all_payment_providers())
i_numbers = Invoice.objects.filter(
order=OuterRef('order_id'),
).values('order').annotate(
m=GroupConcat('full_invoice_no', delimiter=', ')
).values(
'm'
).order_by()
payments = OrderPayment.objects.filter(
order__event__in=self.events,
state__in=form_data.get('payment_states', [])
).annotate(
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
).select_related('order').prefetch_related('order__event').order_by('created')
refunds = OrderRefund.objects.filter(
order__event__in=self.events,
state__in=form_data.get('refund_states', [])
).annotate(
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
).select_related('order').prefetch_related('order__event').order_by('created')
if form_data.get('end_date_range'):
@@ -1135,6 +1148,7 @@ class PaymentListExporter(ListExporter):
headers = [
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'),
_('Invoice numbers'),
]
yield headers
@@ -1172,6 +1186,7 @@ class PaymentListExporter(ListExporter):
obj.comment if isinstance(obj, OrderRefund) else "",
matching_id,
payment_details,
obj.order_invoice_numbers,
]
yield row
+8 -3
View File
@@ -61,18 +61,23 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
yield headers
yield self.ProgressSetTotal(total=media.count())
can_read_giftcards = self.permission_holder.has_organizer_permission(self.organizer, 'organizer.giftcards:read')
for medium in media.iterator(chunk_size=1000):
row = [
giftcard_secret = medium.linked_giftcard.secret if medium.linked_giftcard_id else ''
if giftcard_secret and not can_read_giftcards:
giftcard_secret = giftcard_secret[:3] + ""
yield [
medium.type,
medium.identifier,
_('Yes') if medium.active else _('No'),
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
medium.customer.identifier if medium.customer_id else '',
f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
medium.linked_giftcard.secret if medium.linked_giftcard_id else '',
giftcard_secret,
medium.notes,
]
yield row
def get_filename(self):
return f'{self.organizer.slug}_media'
+36 -16
View File
@@ -35,6 +35,7 @@
import copy
import json
import logging
import re
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -47,9 +48,7 @@ from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import (
MaxValueValidator, MinValueValidator, RegexValidator,
)
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
@@ -90,7 +89,7 @@ from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import rich_text
from pretix.base.templatetags.rich_text import URL_RE, rich_text
from pretix.base.timemachine import time_machine_now
from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
@@ -220,16 +219,8 @@ class NamePartsFormField(forms.MultiValueField):
defaults = {
'widget': self.widget,
'max_length': kwargs.pop('max_length', None),
'validators': [
RegexValidator(
# The following characters should never appear in a name anywhere of
# the world. However, they commonly appear in inputs generated by spam
# bots.
r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.')
)
]
}
self.max_length = defaults['max_length']
self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
@@ -249,7 +240,6 @@ class NamePartsFormField(forms.MultiValueField):
if fname == 'title' and self.scheme_titles:
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
@@ -258,7 +248,6 @@ class NamePartsFormField(forms.MultiValueField):
elif fname == 'salutation':
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[
@@ -287,9 +276,40 @@ class NamePartsFormField(forms.MultiValueField):
if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
if sum(len(v) for v in value.values() if v) > 250:
if sum(len(v) for v in value.values() if v) > (self.max_length or 250):
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
for fname, label, size in self.scheme['fields']:
if fname == 'salutation' or (fname == 'title' and self.scheme_titles):
continue
v = value.get(fname)
if not v:
continue
special_chars = re.findall('[$€/%§{}<>~]', v)
if special_chars:
raise forms.ValidationError(
_('The field "%(label)s" may not contain special characters such as "%(chars)s".'),
code='name_special_chars',
params={
"label": label,
"chars": "".join(special_chars),
},
)
# URL_RE checks for valid domain names, including one special TLD med, which can be part of a title
if ".med" in v:
v = v.replace(".med", ". med")
value[fname] = v
url_matched = URL_RE.search(v)
if url_matched:
raise forms.ValidationError(
_('The field "%(label)s" may not contain an URL (%(url)s).'),
code='url_in_title',
params={
"label": label,
"url": url_matched.group(0),
}
)
if value.get("salutation") == "empty":
value["salutation"] = ""
+77 -91
View File
@@ -22,9 +22,7 @@
import datetime
import logging
import math
import re
import textwrap
import unicodedata
from collections import defaultdict
from decimal import Decimal
from io import BytesIO
@@ -58,8 +56,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, register_ttf_font_if_new,
reshaper,
FontFallbackParagraph, PlainTextParagraph, ThumbnailingImageReader,
normalize_text, register_ttf_font_if_new, reshaper,
)
from pretix.presale.style import get_fonts
@@ -259,18 +257,8 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
def _normalize(self, text):
# reportlab does not support unicode combination characters
# It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFKC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "<br />".join(get_display(reshaper.reshape(l)) for l in re.split("<br ?/>", text))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
# alias kept for plugin compatibility
return normalize_text(text)
def _upper(self, val):
# We uppercase labels, but not in every language
@@ -351,10 +339,15 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
return 'invoice.pdf', 'application/pdf', buffer.read()
def _clean_text(self, text, tags=None):
return self._normalize(bleach.clean(
text,
tags=set(tags) if tags else set()
).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
# For backwards compatibility with customer content, we need to support tags like <br> and <b> in a few text
# fields. Therefore, we can't use PlainTextParagraph for these, but run bleach instead to limit the allowed
# tags.
return self._normalize(
bleach.clean(
text,
tags=set(tags) if tags else set()
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
)
class PaidMarker(Flowable):
@@ -405,8 +398,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas):
p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p = PlainTextParagraph(self.invoice.address_invoice_to, style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
@@ -417,8 +409,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas):
p = FontFallbackParagraph(
self._clean_text(self.invoice.full_invoice_from),
p = PlainTextParagraph(
self.invoice.full_invoice_from,
style=self.stylesheet['InvoiceFrom']
)
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
@@ -548,13 +540,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_event(self, canvas):
def shorten(txt):
txt = str(txt)
txt = bleach.clean(txt, tags=set()).strip()
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
return txt
@@ -572,7 +563,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
else:
p_str = shorten(self.invoice.event.name)
p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = PlainTextParagraph(p_str, style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(self.event_width, self.event_height)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
@@ -645,39 +636,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
type_info_text = self.invoice.transmission_type_instance.pdf_info_text()
if type_info_text:
story.append(FontFallbackParagraph(
story.append(PlainTextParagraph(
type_info_text,
self.stylesheet['WarningBlock']
))
if self.invoice.custom_field:
story.append(FontFallbackParagraph(
story.append(PlainTextParagraph(
'{}: {}'.format(
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
self._clean_text(self.invoice.custom_field),
str(self.invoice.event.settings.invoice_address_custom_field),
self.invoice.custom_field,
),
self.stylesheet['Normal']
))
if self.invoice.internal_reference:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format(
reference=self._clean_text(self.invoice.internal_reference),
)),
story.append(PlainTextParagraph(
pgettext('invoice', 'Customer reference: {reference}').format(
reference=self.invoice.internal_reference,
),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_vat_id:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' +
self._clean_text(self.invoice.invoice_to_vat_id),
story.append(PlainTextParagraph(
pgettext('invoice', 'Customer VAT ID') + ': ' + self.invoice.invoice_to_vat_id,
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' +
self._clean_text(self.invoice.invoice_to_beneficiary),
story.append(PlainTextParagraph(
pgettext('invoice', 'Beneficiary') + ':\n' + self.invoice.invoice_to_beneficiary,
self.stylesheet['Normal']
))
@@ -707,11 +696,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story = [
NextPageTemplate('FirstPage'),
FontFallbackParagraph(
self._normalize(
PlainTextParagraph(
(
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
else pgettext('invoice', 'Invoice')
) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')),
) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'),
self.stylesheet['Heading1']
),
Spacer(1, 5 * mm),
@@ -733,17 +722,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
]
if has_taxes:
tdata = [(
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Net'), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Gross'), self.stylesheet['BoldRightNoSplit']),
)]
else:
tdata = [(
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Amount'), self.stylesheet['BoldRightNoSplit']),
)]
def _group_key(line):
@@ -780,8 +769,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
max_height = self.stylesheet['Normal'].leading * 5
p_style = self.stylesheet['Normal']
for __ in range(1000):
p = FontFallbackParagraph(
self._clean_text(curr_description, tags=['br']),
p = PlainTextParagraph(
curr_description,
p_style
)
h = p.wrap(max_width, doc.height)[1]
@@ -862,7 +851,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
# Group together at the end of the invoice
request_show_service_date = period_line
elif period_line:
description_p_list.append(FontFallbackParagraph(
description_p_list.append(PlainTextParagraph(
period_line,
self.stylesheet['Fineprint']
))
@@ -874,7 +863,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description_p_list.append(FontFallbackParagraph(
description_p_list.append(PlainTextParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
@@ -883,11 +872,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
description_p_list.pop(0),
str(len(lines)),
localize(tax_rate) + " %",
FontFallbackParagraph(
PlainTextParagraph(
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
FontFallbackParagraph(
PlainTextParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
@@ -904,14 +893,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency),
)
description_p_list.append(FontFallbackParagraph(
description_p_list.append(PlainTextParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
tdata.append((
description_p_list.pop(0),
str(len(lines)),
FontFallbackParagraph(
PlainTextParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
@@ -944,12 +933,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if has_taxes:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency)
])
else:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency)
])
@@ -958,12 +947,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
pending_sum = self.invoice.order.pending_sum
if pending_sum != total:
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
[PlainTextParagraph(pgettext('invoice', 'Received payments'), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum - total, self.invoice.event.currency)]
)
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
[PlainTextParagraph(pgettext('invoice', 'Outstanding payments'), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum, self.invoice.event.currency)]
)
@@ -980,12 +969,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
s=Sum('amount')
)['s'] or Decimal('0.00')
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
[PlainTextParagraph(pgettext('invoice', 'Paid by gift card'), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(giftcard_sum, self.invoice.event.currency)]
)
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
[PlainTextParagraph(pgettext('invoice', 'Remaining amount'), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
)
@@ -1008,14 +997,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 10 * mm))
if request_show_service_date:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
story.append(PlainTextParagraph(
pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date),
self.stylesheet['Normal']
))
if self.invoice.payment_provider_text:
story.append(FontFallbackParagraph(
self._normalize(self.invoice.payment_provider_text),
self._clean_text(self.invoice.payment_provider_text, tags=['br', 'b']),
self.stylesheet['Normal']
))
@@ -1039,10 +1028,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
]
thead = [
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['Fineprint']),
PlainTextParagraph(pgettext('invoice', 'Net value'), self.stylesheet['FineprintRight']),
PlainTextParagraph(pgettext('invoice', 'Gross value'), self.stylesheet['FineprintRight']),
PlainTextParagraph(pgettext('invoice', 'Tax'), self.stylesheet['FineprintRight']),
''
]
tdata = [thead]
@@ -1053,7 +1042,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
continue
tax = taxvalue_map[idx]
tdata.append([
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
money_filter(gross - tax, self.invoice.event.currency),
money_filter(gross, self.invoice.event.currency),
money_filter(tax, self.invoice.event.currency),
@@ -1072,7 +1061,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
PlainTextParagraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
table
]))
@@ -1089,7 +1078,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net = gross - tax
tdata.append([
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
fmt(net), fmt(gross), fmt(tax), ''
])
@@ -1098,13 +1087,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(KeepTogether([
Spacer(1, height=2 * mm),
FontFallbackParagraph(
self._normalize(pgettext(
PlainTextParagraph(
pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, this corresponds to:'
).format(rate=localize(self.invoice.foreign_currency_rate),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT")),
self.stylesheet['Fineprint']
),
Spacer(1, height=3 * mm),
@@ -1113,14 +1102,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
story.append(Spacer(1, 5 * mm))
story.append(FontFallbackParagraph(self._normalize(
story.append(PlainTextParagraph(
pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, the invoice total corresponds to {total}.'
).format(rate=localize(self.invoice.foreign_currency_rate),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
total=fmt(foreign_total))),
total=fmt(foreign_total)),
self.stylesheet['Fineprint']
))
@@ -1160,13 +1149,10 @@ class Modern1Renderer(ClassicInvoiceRenderer):
return stylesheet
def _draw_invoice_from(self, canvas):
if not self.invoice.invoice_from:
if not self.invoice.address_invoice_from:
return
c = [
self._clean_text(l)
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
c = self.invoice.address_invoice_from.strip().split('\n')
p = PlainTextParagraph(' · '.join(c), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas)
@@ -1225,8 +1211,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
]
p = FontFallbackParagraph(
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")),
p = PlainTextParagraph(
date_format(self.invoice.date, "DATE_FORMAT"),
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
)
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
@@ -1283,7 +1269,7 @@ class Modern1SimplifiedRenderer(Modern1Renderer):
i = []
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
i.append(FontFallbackParagraph(
i.append(PlainTextParagraph(
pgettext('invoice', 'Event date: {date_range}').format(
date_range=self.invoice.event.get_date_range_display(),
),
@@ -0,0 +1,59 @@
#
# 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/>.
#
"""This command supersedes the Django-inbuilt runserver command.
It runs the local frontend server, if node is installed and the setting
is set.
"""
import atexit
import os
import subprocess
from pathlib import Path
from django.conf import settings
from django.contrib.staticfiles.management.commands.runserver import (
Command as Parent,
)
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
class Command(Parent):
def handle(self, *args, **options):
# Only start Vite in the non-main process of the autoreloader
if settings.VITE_DEV_MODE and os.environ.get(DJANGO_AUTORELOAD_ENV) != "true":
# Start the vite server in the background
vite_server = subprocess.Popen(
["npm", "run", "dev:control"],
cwd=Path(__file__).parent.parent.parent.parent.parent
)
def cleanup():
vite_server.terminate()
try:
vite_server.wait(timeout=5)
except subprocess.TimeoutExpired:
vite_server.kill()
atexit.register(cleanup)
super().handle(*args, **options)
+26 -1
View File
@@ -24,6 +24,7 @@ from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.exceptions import BadRequest
from django.http import Http404, HttpRequest, HttpResponse
from django.middleware.common import CommonMiddleware
from django.urls import get_script_prefix, resolve
@@ -280,7 +281,7 @@ class SecurityMiddleware(MiddlewareMixin):
h = {
'default-src': ["{static}"],
'script-src': ['{static}'],
'script-src': ["{static}"],
'object-src': ["'none'"],
'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"],
@@ -294,6 +295,18 @@ class SecurityMiddleware(MiddlewareMixin):
# this. However, we'll restrict it to HTTPS.
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
}
if settings.VITE_DEV_MODE:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"]
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
if hasattr(request, 'csp_nonce'):
nonce = f"'nonce-{request.csp_nonce}'"
h['script-src'].append(nonce)
if not settings.VITE_DEV_MODE:
# can't have 'unsafe-inline' and nonce at the same time
h['style-src'].append(nonce)
# Only include pay.google.com for wallet detection purposes on the Payment selection page
if (
url.url_name == "event.order.pay.change" or
@@ -347,6 +360,18 @@ class SecurityMiddleware(MiddlewareMixin):
return resp
class RejectInvalidInputMiddleware(MiddlewareMixin):
def process_request(self, request):
# Nullbytes in GET/POST parameters are mostly harmless, as they will later fail on database insertion, but it
# keeps spamming our error logs whenever someone tries to run a vulnerability scanner.
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
raise BadRequest("Invalid characters in input.")
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
raise BadRequest("Invalid characters in input.")
class CustomCommonMiddleware(CommonMiddleware):
def get_full_path_with_slash(self, request):
@@ -0,0 +1,19 @@
# Generated by Django 4.2.27 on 2026-01-21 12:06
import i18nfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0298_pluggable_permissions"),
]
operations = [
migrations.AddField(
model_name="itemprogramtime",
name="location",
field=i18nfield.fields.I18nTextField(max_length=200, null=True),
)
]
+1 -1
View File
@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
@property
def verbose_name(self):
return _('Attendee address') + ': ' + _('State')
return _('Attendee address') + ': ' + pgettext('address', 'State')
def clean(self, value, previous_values):
if value:
+1 -1
View File
@@ -125,7 +125,7 @@ class LoggingMixin:
elif isinstance(self, Event):
event = self
organizer_id = self.organizer_id
elif hasattr(self, 'event'):
elif hasattr(self, 'event') and self.event:
event = self.event
organizer_id = self.event.organizer_id
elif hasattr(self, 'organizer_id'):
+6
View File
@@ -715,6 +715,12 @@ class Event(EventMixin, LoggedModel):
self.settings.name_scheme = 'given_family'
self.settings.payment_banktransfer_invoice_immediately = True
self.settings.low_availability_percentage = 10
self.settings.mail_send_order_free_attendee = True
self.settings.mail_send_order_placed_attendee = True
self.settings.mail_send_order_paid_attendee = True
self.settings.mail_send_order_approved_attendee = True
self.settings.mail_send_order_approved_free_attendee = True
self.settings.mail_text_download_reminder_attendee = True
@property
def social_image(self):
+7
View File
@@ -2306,10 +2306,17 @@ class ItemProgramTime(models.Model):
:type start: datetime
:param end: The date and time this program time ends
:type end: datetime
:param location: venue
:type location: str
"""
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
start = models.DateTimeField(verbose_name=_("Start"))
end = models.DateTimeField(verbose_name=_("End"))
location = I18nTextField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
)
def clean(self):
if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
+17 -11
View File
@@ -498,9 +498,9 @@ DEFAULT_VARIABLES = OrderedDict((
) if op.valid_until else ""
}),
("program_times", {
"label": _("Program times: date and time"),
"label": _("Program times"),
"editor_sample": _(
"2017-05-31 10:00 12:00\n2017-05-31 14:00 16:00\n2017-05-31 14:00 2017-06-01 14:00"),
"2017-05-31 10:00 12:00, Room 1\n2017-05-31 14:00 16:00, Room 2\n2017-05-31 14:00 2017-06-01 14:00, Building A"),
"evaluate": lambda op, order, ev: get_program_times(op, ev)
}),
("medium_identifier", {
@@ -748,13 +748,19 @@ def get_seat(op: OrderPosition):
def get_program_times(op: OrderPosition, ev: Event):
return '\n'.join([
datetimerange(
pt.start.astimezone(ev.timezone),
pt.end.astimezone(ev.timezone),
as_html=False
) for pt in op.item.program_times.all()
])
ptstr = []
for pt in op.item.program_times.all():
ptstr.append([
datetimerange(
pt.start.astimezone(ev.timezone),
pt.end.astimezone(ev.timezone),
as_html=False
),
(', ' + ', '.join(
l.strip() for l in str(pt.location).splitlines() if l.strip())
) if str(pt.location).strip() else ''
])
return '\n'.join(''.join(l) for l in ptstr)
def generate_compressed_addon_list(op, order, event, only_checked_in=False):
@@ -923,7 +929,7 @@ class Renderer:
# We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this
# 1:1 on other platforms that render PDFs through our API (libpretixprint)
return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
return re.sub(r'\{([-a-zA-Z0-9:_]+)\}', replace, text)
elif o['content'].startswith('itemmeta:'):
if op.variation_id:
@@ -1056,7 +1062,7 @@ class Renderer:
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style)
p = Paragraph(text, style=style) # not using AutoEscapeParagraph is safe as we escape above
return p, ad, lineheight
def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
+29 -24
View File
@@ -49,14 +49,39 @@ class PluginType(Enum):
EXPORT = 4
def plugin_is_available(meta, event=None, organizer=None):
if not hasattr(meta.app, 'is_available'):
return True
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event:
return meta.app.is_available(event)
elif organizer:
if not hasattr(organizer, '_plugin_availability_fallback_event'):
with scope(organizer=organizer):
setattr(organizer, '_plugin_availability_fallback_event', organizer.events.first())
return (
organizer._plugin_availability_fallback_event
and meta.app.is_available(organizer._plugin_availability_fallback_event)
)
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer:
return meta.app.is_available(organizer)
elif event:
return meta.app.is_available(event.organizer)
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer):
return meta.app.is_available(event or organizer)
return True
def get_all_plugins(*, event=None, organizer=None) -> List[type]:
"""
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
"""
assert not event or not organizer
plugins = []
event_fallback = None
event_fallback_used = False
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
meta = app.PretixPluginMeta
@@ -65,28 +90,8 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'):
if not app.is_available(event):
continue
elif organizer and hasattr(app, 'is_available'):
if not event_fallback_used:
with scope(organizer=organizer):
event_fallback = organizer.events.first()
event_fallback_used = True
if not event_fallback or not app.is_available(event_fallback):
continue
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer and hasattr(app, 'is_available'):
if not app.is_available(organizer):
continue
elif event and hasattr(app, 'is_available'):
if not app.is_available(event.organizer):
continue
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'):
if not app.is_available(event or organizer):
continue
if not plugin_is_available(meta, event, organizer):
continue
plugins.append(meta)
return sorted(
+2 -2
View File
@@ -162,12 +162,12 @@ error_messages = {
'price_too_high': gettext_lazy('The entered price is to high.'),
'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'),
'voucher_min_usages': ngettext_lazy(
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
'number'
),
'voucher_min_usages_removed': ngettext_lazy(
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product. '
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
+28
View File
@@ -38,6 +38,7 @@ SOURCE_NAMES = {
None: _('European Central Bank'), # backwards-compatibility
'eu:ecb:eurofxref-daily': _('European Central Bank'),
'cz:cnb:rate-fixing-daily': _('Czech National Bank'),
'pl:nbp:table-a': _('National Bank of Poland'),
}
@@ -49,6 +50,7 @@ def fetch_rates(sender, **kwargs):
source_tasks = {
'eu:ecb:eurofxref-daily': fetch_ecb_rates,
'cz:cnb:rate-fixing-daily': fetch_cnb_cz_rates,
'pl:nbp:table-a': fetch_nbp_pl_rates,
}
for source_name, task in source_tasks.items():
@@ -144,3 +146,29 @@ def fetch_cnb_cz_rates():
rate=rate,
)
)
@app.task()
def fetch_nbp_pl_rates():
"""
Fetches currency rates from the Polish National Bank.
"""
r = requests.get("https://api.nbp.pl/api/exchangerates/tables/A/", headers={
"Accept": "application/json",
})
r.raise_for_status()
data = r.json()[0]
source_date = datetime.strptime(data["effectiveDate"], "%Y-%m-%d").date()
for r in data["rates"]:
rate = Decimal(r["mid"]).quantize(Decimal('0.000001'))
ExchangeRate.objects.update_or_create(
source='pl:nbp:table-a',
source_currency=r["code"],
other_currency='PLN',
defaults=dict(
source_date=source_date,
rate=rate,
)
)
+15 -1
View File
@@ -58,6 +58,7 @@ from pretix.base.invoicing.transmission import (
from pretix.base.models import (
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
)
from pretix.base.models.orders import OrderPayment
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import (
TransactionAwareProfiledEventTask, TransactionAwareTask,
@@ -102,7 +103,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if lp and lp.payment_provider:
if lp and lp.payment_provider and lp.state not in (OrderPayment.PAYMENT_STATE_FAILED, OrderPayment.PAYMENT_STATE_CANCELED):
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
else:
@@ -204,6 +205,19 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
invoice.foreign_currency_rate_date = rate.source_date
invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily'
elif invoice.event.settings.invoice_eu_currencies == 'PLN' and invoice.event.currency != 'PLN':
invoice.foreign_currency_display = 'PLN'
if settings.FETCH_ECB_RATES:
rate = ExchangeRate.objects.filter(
source='pl:nbp:table-a',
source_currency=invoice.event.currency,
other_currency=invoice.foreign_currency_display,
source_date__gt=now().date() - timedelta(days=7)
).first()
if rate:
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
invoice.foreign_currency_rate_date = rate.source_date
invoice.foreign_currency_source = 'pl:nbp:table-a'
except InvoiceAddress.DoesNotExist:
ia = None
+3 -2
View File
@@ -727,8 +727,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
_check_date(event, time_machine_now_dt)
products_seen = Counter()
q_avail = Counter()
v_avail = Counter()
v_usages = Counter()
v_budget = {}
deleted_positions = set()
@@ -793,6 +791,9 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
shared_lock_objects=[event]
)
q_avail = Counter()
v_avail = Counter()
# Check maximum order size
limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE)
if sum(1 for cp in sorted_positions if not cp.addon_to) > limit:
File diff suppressed because one or more lines are too long
@@ -55,10 +55,12 @@
{% trans "You receive these emails based on your notification settings." %}<br>
<a href="{{ settings_url }}">
{% trans "Click here to view and change your notification settings" %}
</a><br>
<a href="{{ disable_url }}">
{% trans "Click here disable all notifications immediately." %}
</a>
{% if disable_url %}<br>
<a href="{{ disable_url }}">
{% trans "Click here disable all notifications immediately." %}
</a>
{% endif %}
</div>
<!--[if gte mso 9]>
</td></tr></table>
@@ -14,5 +14,6 @@
{% trans "You receive these emails based on your notification settings." %}
{% trans "Click here to view and change your notification settings:" %}
{{ settings_url }}
{% trans "Click here disable all notifications immediately:" %}
{% if disable_url %}{% trans "Click here disable all notifications immediately:" %}
{{ disable_url }}
{% endif %}
@@ -2,13 +2,14 @@
{% load i18n %}
{% load rich_text %}
{% load static %}
{% load wrap_in %}
{% block title %}{% trans "Redirect" %}{% endblock %}
{% block content %}
<i class="fa fa-link fa-fw big-icon"></i>
<div class="error-details">
<h1>{% trans "Redirect" %}</h1>
<h3>
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
{% blocktrans trimmed with host=hostname|wrap_in:'strong' %}
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
{% endblocktrans %}
{% blocktrans trimmed %}
+32
View File
@@ -0,0 +1,32 @@
#
# 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 django import template
from django.conf import settings
register = template.Library()
@register.filter
def human_readable_locale(value):
if not value:
return ''
return dict(settings.LANGUAGES).get(value, '')
+243
View File
@@ -0,0 +1,243 @@
#
# 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/>.
#
import json
import logging
import pathlib
import re
import secrets
from urllib.parse import urljoin
from urllib.request import urlopen
import importlib_metadata as metadata
from django import template
from django.conf import settings
from django.utils.safestring import mark_safe
register = template.Library()
LOGGER = logging.getLogger(__name__)
_MANIFEST = {}
# TODO more os.path.join ?
MANIFEST_PATH = settings.STATIC_ROOT + "/vite/control/.vite/manifest.json"
MANIFEST_BASE = "vite/control/"
# entry_name -> {"manifest_entry": {...}, "url_base": "..."}
_PLUGIN_REGISTRY = {}
def _discover_plugin_manifests():
"""Discover plugin vite manifests at startup.
Scans installed pretix plugins for a .vite/manifest.json inside a static.dist
directory. Only non-editable (wheel) plugins are expected to ship pre-built
assets; editable plugins are served through the Vite dev server.
"""
for ep in metadata.entry_points(group='pretix.plugin'):
dist = ep.dist
if not dist or not dist.files:
continue
try:
url_info = json.loads(dist.read_text('direct_url.json') or '{}')
if url_info.get('dir_info', {}).get('editable', False):
continue # editable plugins are served via vite dev server
except Exception:
pass
# Find .vite/manifest.json inside a /static/ directory
try:
manifest_rel = None
for f in dist.files:
if f.name == 'manifest.json' and '/static/' in str(f) and '/.vite/' in str(f):
manifest_rel = f
break
if not manifest_rel:
continue
manifest_path = pathlib.Path(str(dist.locate_file(manifest_rel)))
if not manifest_path.exists():
continue
plugin_manifest = json.loads(manifest_path.read_text())
url_base = re.search(r'/static/(.+?)/\.vite/', str(manifest_rel)).group(1) + '/'
for _key, entry in plugin_manifest.items():
if entry.get('isEntry') and 'name' in entry:
_PLUGIN_REGISTRY[entry['name']] = {
'manifest_entry': entry,
'url_base': url_base,
}
except Exception:
LOGGER.warning(f"Failed to discover vite manifest for plugin {ep.name}", exc_info=True)
# Load core manifest
if not settings.VITE_DEV_MODE and not settings.VITE_IGNORE:
try:
with open(MANIFEST_PATH) as fp:
_MANIFEST = json.load(fp)
except Exception as e:
LOGGER.warning(f"Error reading vite manifest at {MANIFEST_PATH}: {str(e)}")
# Discover plugin manifests
if not settings.VITE_IGNORE:
_discover_plugin_manifests()
def _generate_script_tag(path, attrs, src=None):
all_attrs = " ".join(f'{key}="{value}"' for key, value in attrs.items())
if src is None:
if settings.VITE_DEV_MODE:
src = urljoin(settings.VITE_DEV_SERVER, path)
else:
src = urljoin(settings.STATIC_URL, path)
return f'<script {all_attrs} src="{src}"></script>'
def _generate_css_tags(asset, already_processed=None):
"""Recursively builds all CSS tags used in a given asset from the core manifest."""
tags = []
manifest_entry = _MANIFEST[asset]
if already_processed is None:
already_processed = []
if "css" in manifest_entry:
for css_path in manifest_entry["css"]:
if css_path not in already_processed:
full_path = urljoin(settings.STATIC_URL, MANIFEST_BASE + css_path)
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
already_processed.append(css_path)
if "imports" in manifest_entry:
for import_path in manifest_entry["imports"]:
tags += _generate_css_tags(import_path, already_processed)
return tags
def _generate_plugin_css_tags(manifest_entry, url_base):
"""Build CSS tags for a plugin manifest entry."""
tags = []
if "css" in manifest_entry:
for css_path in manifest_entry["css"]:
full_path = urljoin(settings.STATIC_URL, url_base + css_path)
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
return tags
@register.simple_tag
@mark_safe
def vite_asset(path):
"""
Generates one <script> tag and <link> tags for each of the CSS dependencies.
"""
if not path:
return ""
# Check plugin registry (non-editable plugins with pre-built assets)
if path in _PLUGIN_REGISTRY:
info = _PLUGIN_REGISTRY[path]
entry = info['manifest_entry']
url_base = info['url_base']
tags = _generate_plugin_css_tags(entry, url_base)
# Always use STATIC_URL for pre-built plugin assets, even in dev mode
src = urljoin(settings.STATIC_URL, url_base + entry["file"])
tags.append(_generate_script_tag(path, {"type": "module", "crossorigin": ""}, src=src))
return "".join(tags)
# Dev mode: editable plugins and core entries go through the vite dev server
if settings.VITE_DEV_MODE:
return _generate_script_tag(path, {"type": "module"})
# Prod mode
manifest_entry = _MANIFEST.get(path)
if not manifest_entry:
raise RuntimeError(f"Cannot find {path} in Vite manifest at {MANIFEST_PATH}")
tags = _generate_css_tags(path)
tags.append(
_generate_script_tag(
MANIFEST_BASE + manifest_entry["file"], {"type": "module", "crossorigin": ""}
)
)
return "".join(tags)
@register.simple_tag
@mark_safe
def vite_hmr():
if not settings.VITE_DEV_MODE:
return ""
return _generate_script_tag("@vite/client", {"type": "module"})
_dev_importmap_cache = None
def _get_dev_importmap():
"""Fetch the shared-dep import map from the Vite dev server. Cached after first call."""
global _dev_importmap_cache
if _dev_importmap_cache is not None:
return _dev_importmap_cache
try:
url = urljoin(settings.VITE_DEV_SERVER, "/__pretix_importmap")
raw = json.loads(urlopen(url, timeout=2).read())
_dev_importmap_cache = {
dep: urljoin(settings.VITE_DEV_SERVER, dep_path)
for dep, dep_path in raw.items()
}
except Exception:
LOGGER.warning("Failed to fetch import map from Vite dev server")
_dev_importmap_cache = {}
return _dev_importmap_cache
@register.simple_tag(takes_context=True)
@mark_safe
def vite_importmap(context):
"""Emit an import map so pre-built plugin assets can resolve shared dependencies like vue."""
imports = {}
if settings.VITE_DEV_MODE:
# Fetch the import map from the Vite dev server (served by sharedDepsPlugin)
imports.update(_get_dev_importmap())
else:
# Discover all _vendor/* entries from the core manifest
for _key, entry in _MANIFEST.items():
name = entry.get("name", "")
if name.startswith("_vendor/"):
bare_specifier = name[len("_vendor/"):]
imports[bare_specifier] = urljoin(settings.STATIC_URL, MANIFEST_BASE + entry["file"])
if not imports:
return ""
# Generate a nonce and store it on the request so the CSP middleware can allow it
nonce = secrets.token_urlsafe(16)
request = context.get('request')
if request:
request.csp_nonce = nonce
return f'<script type="importmap" nonce="{nonce}">{json.dumps({"imports": imports})}</script>'
+30 -1
View File
@@ -24,10 +24,12 @@ import calendar
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.core.validators import RegexValidator, validate_email
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
from pretix.base.templatetags.rich_text import URL_RE
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
@@ -113,6 +115,33 @@ def multimail_validate(val):
return s
class RegexValidatorInverseMatchAndParam(RegexValidator):
inverse_match = True
def __call__(self, value):
regex_matches = self.regex.search(str(value))
if regex_matches:
raise ValidationError(
self.message,
code=self.code,
params={
"value": value,
"match": regex_matches.group(0) if regex_matches else "",
}
)
class NoUrlValidator(RegexValidatorInverseMatchAndParam):
regex = URL_RE
def __init__(self, **kwargs):
if not kwargs.get("message"):
kwargs["message"] = _('You entered an URL, which is not allowed. Please remove %(match)s from your input.')
if not kwargs.get("code"):
kwargs["code"] = "contains_url"
super().__init__(**kwargs)
class RRuleValidator:
def __init__(self, enforce_simple=False):
self.enforce_simple = enforce_simple
+4
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import logging
import re
from collections import defaultdict
from datetime import timedelta
from importlib import import_module
@@ -52,6 +53,7 @@ from pretix.celery_app import app
from pretix.helpers.http import redirect_to_url
logger = logging.getLogger('pretix.base.tasks')
RE_ASYNC_ID = re.compile(r"^[a-zA-Z0-9\-]+$")
class AsyncMixin:
@@ -133,6 +135,8 @@ class AsyncMixin:
def get_result(self, request):
if not request.GET.get('async_id'):
raise BadRequest("No async_id given")
if not RE_ASYNC_ID.match(request.GET.get('async_id')):
raise BadRequest("Invalid async_id given")
res = AsyncResult(request.GET.get('async_id'))
if 'ajax' in self.request.GET:
return JsonResponse(self._return_ajax_result(res, timeout=0.25))
+127
View File
@@ -1528,6 +1528,133 @@ class SubEventFilterForm(FilterForm):
return self.event.organizer.meta_properties.filter(filter_allowed=True)
class QuotaFilterForm(FilterForm):
orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
subevent = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
queryset=SubEvent.objects.none(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
date_from = forms.DateField(
label=_('Date from'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date from'),
}),
)
date_until = forms.DateField(
label=_('Date until'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date until'),
}),
)
time_from = forms.TimeField(
label=_('Start time from'),
required=False,
widget=TimePickerWidget({}),
)
time_until = forms.TimeField(
label=_('Start time until'),
required=False,
widget=TimePickerWidget({}),
)
weekday = forms.MultipleChoiceField(
label=_('Weekday'),
choices=(
('2', _('Monday')),
('3', _('Tuesday')),
('4', _('Wednesday')),
('5', _('Thursday')),
('6', _('Friday')),
('7', _('Saturday')),
('1', _('Sunday')),
),
widget=forms.CheckboxSelectMultiple,
required=False
)
query = forms.CharField(
label=_('Quota name'),
widget=forms.TextInput(),
required=False
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['date_from'].widget = DatePickerWidget()
self.fields['date_until'].widget = DatePickerWidget()
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']
del self.fields['date_from']
del self.fields['date_until']
del self.fields['time_from']
del self.fields['time_until']
del self.fields['weekday']
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('weekday'):
qs = qs.annotate(wday=ExtractWeekDay('subevent__date_from')).filter(wday__in=fdata.get('weekday'))
if fdata.get('subevent'):
qs = qs.filter(subevent=fdata["subevent"])
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(name__icontains=query)
if fdata.get('date_until'):
date_end = make_aware(datetime.combine(
fdata.get('date_until') + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(
Q(subevent__date_to__isnull=True, subevent__date_from__lt=date_end) |
Q(subevent__date_to__isnull=False, subevent__date_to__lt=date_end)
)
if fdata.get('date_from'):
date_start = make_aware(datetime.combine(
fdata.get('date_from'),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(subevent__date_from__gte=date_start)
if fdata.get('time_until'):
qs = qs.filter(subevent__date_from__time__lte=fdata.get('time_until'))
if fdata.get('time_from'):
qs = qs.filter(subevent__date_from__time__gte=fdata.get('time_from'))
if fdata.get('ordering'):
qs = qs.order_by(*get_deterministic_ordering(Quota, self.get_order_by()))
else:
qs = qs.order_by('-subevent__date_from', 'name', 'pk')
return qs
class OrganizerFilterForm(FilterForm):
orders = {
'slug': 'slug',
@@ -104,6 +104,13 @@ class GlobalSettingsForm(SettingsForm):
help_text=_("Will be served at {domain}/.well-known/apple-developer-merchantid-domain-association").format(
domain=settings.SITE_URL
)
)),
('widget_vite_origins', forms.CharField(
widget=forms.Textarea(attrs={'rows': '3'}),
required=False,
# Not translated on purpose, this is a temporary feature and contains too many special case words
label="Vite widget origins",
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the new vite-based widget.",
))
])
responses = register_global_settings.send(self)
+61 -1
View File
@@ -43,6 +43,7 @@ from django.core.exceptions import ValidationError
from django.db.models import Max, Q
from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
@@ -375,6 +376,60 @@ class QuotaForm(I18nModelForm):
return inst
class QuotaBulkEditForm(QuotaForm):
def __init__(self, *args, **kwargs):
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(**kwargs)
self.fields.pop("subevent", None) # Would add extra complexity and it's hard to imagine a use case for that
self.fields["name"].required = False
self.fields["itemvars"].required = False
def clean(self):
d = super().clean()
if self.prefix + "name" in self.data.getlist('_bulk') and not d.get("name"):
raise ValidationError({"name": _("This field is required.")})
if self.prefix + "itemvars" in self.data.getlist('_bulk') and not d.get("itemvars"):
raise ValidationError({"itemvars": _("This field is required.")})
return d
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
for k in self.fields:
cb_val = self.prefix + k
if cb_val not in self.data.getlist('_bulk'):
continue
fields.add(k)
if k == 'itemvars':
selected_items = set(list(self.event.items.filter(id__in=[
i.split('-')[0] for i in self.cleaned_data['itemvars']
])))
selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[
i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i
]))
for obj in objs:
obj.items.set(selected_items)
obj.variations.set(selected_variations)
else:
for obj in objs:
setattr(obj, k, self.cleaned_data[k])
fields = [f for f in fields if f != 'itemvars']
if fields:
Quota.objects.bulk_update(objs, fields, 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
class ItemCreateForm(I18nModelForm):
NONE = 'none'
EXISTING = 'existing'
@@ -574,7 +629,7 @@ class ItemCreateForm(I18nModelForm):
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
count=b.count, designated_price=b.designated_price)
for pt in self.cleaned_data['copy_from'].program_times.all():
instance.program_times.create(start=pt.start, end=pt.end)
instance.program_times.create(start=pt.start, end=pt.end, location=pt.location)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
@@ -1354,6 +1409,10 @@ class ItemProgramTimeForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix)
self.fields['location'].widget.attrs['rows'] = '3'
self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center, Heidelberg, Germany'
)
class Meta:
model = ItemProgramTime
@@ -1361,6 +1420,7 @@ class ItemProgramTimeForm(I18nModelForm):
fields = [
'start',
'end',
'location'
]
field_classes = {
'start': forms.SplitDateTimeField,
+11 -1
View File
@@ -28,7 +28,7 @@ from django.forms import formset_factory
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm
@@ -102,6 +102,16 @@ class SubEventBulkForm(SubEventForm):
required=False,
limit_choices=('date_from', 'date_to'),
)
skip_if_overlap = forms.BooleanField(
label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'),
help_text=pgettext_lazy(
'subevent',
'This can be useful if all your dates happen in the same location and no repeated dates should '
'be created in conflict with existing special events. This respects even inactive dates and works best if '
'all dates have both a start and end time.'
),
required=False,
)
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
+6 -6
View File
@@ -34,11 +34,11 @@
# License for the specific language governing permissions and limitations under the License.
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
from typing import Optional
import bleach
import dateutil.parser
from django.dispatch import receiver
from django.urls import reverse
from django.utils.formats import date_format
@@ -248,7 +248,7 @@ class OrderValidFromChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity start date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
'new_value') else ''
)
@@ -260,7 +260,7 @@ class OrderValidUntilChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity end date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
)
@@ -364,7 +364,7 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
data['posid'] = logentry.parsed_data.get('positionid', '?')
if 'datetime' in data:
dt = dateutil.parser.parse(data.get('datetime'))
dt = datetime.fromisoformat(data.get('datetime'))
if abs((logentry.datetime - dt).total_seconds()) > 5 or data.get('forced'):
if event:
data['datetime'] = date_format(dt.astimezone(event.timezone), "SHORT_DATETIME_FORMAT")
@@ -430,7 +430,7 @@ class OrderPrintLogEntryType(OrderLogEntryType):
return _('Position #{posid} has been printed at {datetime} with type "{type}".').format(
posid=data.get('positionid'),
datetime=date_format(
dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone),
datetime.fromisoformat(data["datetime"]).astimezone(logentry.event.timezone),
"SHORT_DATETIME_FORMAT"
) if logentry.event else data["datetime"],
type=dict(PrintLog.PRINT_TYPES)[data["type"]],
@@ -985,7 +985,7 @@ class LegacyCheckinLogEntryType(OrderLogEntryType):
def display(self, logentry, data):
# deprecated
dt = dateutil.parser.parse(data.get('datetime'))
dt = datetime.fromisoformat(data.get('datetime'))
tz = logentry.event.timezone
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in data:
+23 -22
View File
@@ -38,6 +38,7 @@ from pretix import __version__
from pretix.base.models import Order, OrderPayment, Transaction
from pretix.base.plugins import get_all_plugins
from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import PlainTextParagraph
from pretix.plugins.reports.exporters import ReportlabExportMixin
from pretix.settings import DATA_DIR
@@ -79,23 +80,23 @@ class SysReport(ReportlabExportMixin):
style_small.fontSize = 6
story = [
Paragraph("System report", headlinestyle),
PlainTextParagraph("System report", headlinestyle),
Spacer(1, 5 * mm),
Paragraph("Usage", subheadlinestyle),
PlainTextParagraph("Usage", subheadlinestyle),
Spacer(1, 5 * mm),
self._usage_table(),
Spacer(1, 5 * mm),
Paragraph("Installed versions", subheadlinestyle),
PlainTextParagraph("Installed versions", subheadlinestyle),
Spacer(1, 5 * mm),
self._tech_table(),
Spacer(1, 5 * mm),
Paragraph("Plugins", subheadlinestyle),
PlainTextParagraph("Plugins", subheadlinestyle),
Spacer(1, 5 * mm),
Paragraph(self._get_plugin_versions(), style_small),
PlainTextParagraph(self._get_plugin_versions(), style_small),
Spacer(1, 5 * mm),
Paragraph("Custom templates", subheadlinestyle),
PlainTextParagraph("Custom templates", subheadlinestyle),
Spacer(1, 5 * mm),
Paragraph(self._get_custom_templates(), style_small),
PlainTextParagraph(self._get_custom_templates(), style_small),
Spacer(1, 5 * mm),
]
@@ -121,13 +122,13 @@ class SysReport(ReportlabExportMixin):
("RIGHTPADDING", (-1, 0), (-1, -1), 0),
]
tdata = [
[Paragraph("Site URL:", style), Paragraph(settings.SITE_URL, style)],
[Paragraph("pretix version:", style), Paragraph(__version__, style)],
[Paragraph("Python version:", style), Paragraph(sys.version, style)],
[Paragraph("Platform:", style), Paragraph(platform.platform(), style)],
[PlainTextParagraph("Site URL:", style), Paragraph(settings.SITE_URL, style)],
[PlainTextParagraph("pretix version:", style), Paragraph(__version__, style)],
[PlainTextParagraph("Python version:", style), Paragraph(sys.version, style)],
[PlainTextParagraph("Platform:", style), Paragraph(platform.platform(), style)],
[
Paragraph("Database engine:", style),
Paragraph(settings.DATABASES["default"]["ENGINE"], style),
PlainTextParagraph("Database engine:", style),
PlainTextParagraph(settings.DATABASES["default"]["ENGINE"], style),
],
]
table = Table(tdata, colWidths=colwidths, repeatRows=0)
@@ -206,7 +207,7 @@ class SysReport(ReportlabExportMixin):
year_last = now().year
tdata = [
[
Paragraph(l, style_small_head)
PlainTextParagraph(l, style_small_head)
for l in (
"Time frame",
"Currency",
@@ -257,19 +258,19 @@ class SysReport(ReportlabExportMixin):
tdata.append(
(
Paragraph(
PlainTextParagraph(
date_format(first_day, "M Y")
+ " "
+ date_format(after_day - timedelta(days=1), "M Y"),
style_small,
),
Paragraph(c, style_small),
Paragraph(str(orders_count), style_small) if i == 0 else "",
Paragraph(money_filter(revenue_data.get("s_net") or 0, c), style_small),
Paragraph(str(testmode_count), style_small) if i == 0 else "",
Paragraph(str(unconfirmed_count), style_small) if i == 0 else "",
Paragraph(str(revenue_data.get("c") or 0), style_small),
Paragraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small),
PlainTextParagraph(c, style_small),
PlainTextParagraph(str(orders_count), style_small) if i == 0 else "",
PlainTextParagraph(money_filter(revenue_data.get("s_net") or 0, c), style_small),
PlainTextParagraph(str(testmode_count), style_small) if i == 0 else "",
PlainTextParagraph(str(unconfirmed_count), style_small) if i == 0 else "",
PlainTextParagraph(str(revenue_data.get("c") or 0), style_small),
PlainTextParagraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small),
)
)
@@ -2,6 +2,7 @@
{% load static %}
{% load i18n %}
{% load statici18n %}
{% load vite %}
{% load eventsignal %}
{% load eventurl %}
{% load dialog %}
@@ -84,6 +85,7 @@
<meta name="theme-color" content="#3b1c4a">
<meta name="referrer" content="origin">
{% vite_importmap %}
{% block custom_header %}{% endblock %}
</head>
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
@@ -3,6 +3,7 @@
{% load bootstrap3 %}
{% load static %}
{% load compress %}
{% load vite %}
{% block title %}
{% if checkinlist %}
{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
@@ -74,45 +75,8 @@
{% bootstrap_field form.ignore_in_statistics layout="control" %}
<h3>{% trans "Custom check-in rule" %}</h3>
<div id="rules-editor" class="form-inline">
<div>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#rules-edit" role="tab" data-toggle="tab">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</li>
<li role="presentation">
<a href="#rules-viz" role="tab" data-toggle="tab">
<span class="fa fa-eye"></span>
{% trans "Visualize" %}
</a>
</li>
</ul>
<!-- Tab panes -->
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="rules-edit">
<checkin-rules-editor></checkin-rules-editor>
</div>
<div role="tabpanel" class="tab-pane" id="rules-viz">
<checkin-rules-visualization></checkin-rules-visualization>
</div>
</div>
</div>
<div class="alert alert-info" v-if="missingItems.length">
<p>
{% trans "Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:" %}
</p>
<ul>
<li v-for="h in missingItems">{{ "{" }}{h}{{ "}" }}</li>
</ul>
<p>
{% trans "Please double-check if this was intentional." %}
</p>
</div>
<div id="rules-editor">
<!-- Vue app mount point -->
</div>
<div class="disabled-withoutjs sr-only">
{{ form.rules }}
@@ -125,13 +89,10 @@
</button>
</div>
</form>
{{ items|json_script:"items" }}
{% if DEBUG %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
{% else %}
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
{% if items %}
{{ items|json_script:"items" }}
{% endif %}
{% compress js %}
<script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script>
@@ -144,15 +105,6 @@
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
{% endcompress %}
{% compress js %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/lookup-select2.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rule.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
{% endcompress %}
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
{% endblock %}
@@ -5,6 +5,7 @@
{% load getitem %}
{% load static %}
{% load compress %}
{% load vite %}
{% block title %}{% trans "Check-in simulator" %}{% endblock %}
{% block inside %}
<h1>
@@ -124,11 +125,9 @@
{% endif %}
{% if result.rule_graph %}
<div id="rules-editor" class="form-inline">
<div role="tabpanel" class="tab-pane" id="rules-viz">
<checkin-rules-visualization></checkin-rules-visualization>
</div>
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
<!-- Vue app mount point -->
</div>
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
{% endif %}
</div>
</div>
@@ -152,10 +151,6 @@
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
{% endcompress %}
{% compress js %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
{% endcompress %}
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
{% endblock %}
@@ -34,6 +34,7 @@
{% bootstrap_form_errors form %}
{% bootstrap_field form.start layout="control" %}
{% bootstrap_field form.end layout="control" %}
{% bootstrap_field form.location layout="control" %}
</div>
</div>
{% endfor %}
@@ -59,6 +60,7 @@
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.start layout="control" %}
{% bootstrap_field formset.empty_form.end layout="control" %}
{% bootstrap_field formset.empty_form.location layout="control" %}
</div>
</div>
{% endescapescript %}
@@ -0,0 +1,49 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block content %}
<h1>
{% trans "Change multiple quotas" %}
<small>
{% blocktrans trimmed with number=quotas.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="hidden">
{% for d in quotas %}
<input type="hidden" name="quota" value="{{ d.pk }}">
{% endfor %}
</div>
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="bulkedit" %}
{% bootstrap_field form.size layout="bulkedit" %}
</fieldset>
<fieldset>
<legend>{% trans "Items" %}</legend>
<p>
{% blocktrans trimmed %}
Please select the products or product variations this quota should be applied to. If you apply two
quotas to the same product, it will only be available if <strong>both</strong> quotas have capacity
left.
{% endblocktrans %}
</p>
{% bootstrap_field form.itemvars layout="bulkedit" %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced options" %}</legend>
{% bootstrap_field form.close_when_sold_out layout="bulkedit" %}
{% bootstrap_field form.release_after_exit layout="bulkedit" %}
{% bootstrap_field form.ignore_for_event_availability layout="bulkedit" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,34 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete quotas" %}{% endblock %}
{% block content %}
<h1>{% trans "Delete quotas" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if allowed %}
<p>{% blocktrans trimmed count num=allowed|length %}
Are you sure you want to delete the following quota?
{% plural %}
Are you sure you want to delete the following {{ num }} quotas?
{% endblocktrans %}</p>
<ul>
{% for q in allowed %}
<li>
{{ q }} {% if q.subevent %}({{ q.subevent }}){% endif %}
<input type="hidden" name="quota" value="{{ q.pk }}">
</li>
{% endfor %}
</ul>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.items.quotas" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save" value="delete_confirm" name="action">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Quotas" %}{% endblock %}
{% block inside %}
<h1>{% trans "Quotas" %}</h1>
@@ -13,21 +14,12 @@
number of a specific ticket type at the same time.
{% endblocktrans %}
</p>
{% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</form>
{% endif %}
{% if quotas|length == 0 %}
{% if quotas|length == 0 and not filter_form.filtered %}
<div class="empty-collection">
<p>
{% if request.GET.subevent %}
{% trans "Your search did not match any quotas." %}
{% else %}
{% blocktrans trimmed %}
You haven't created any quotas yet.
{% endblocktrans %}
{% endif %}
{% blocktrans trimmed %}
You haven't created any quotas yet.
{% endblocktrans %}
</p>
{% if 'event.items:write' in request.eventpermset %}
@@ -36,79 +28,160 @@
{% endif %}
</div>
{% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Filter" %}
</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="{% if not filter_form.subevent %}col-lg-6{% else %}col-lg-2{% endif %} col-md-6 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query %}
</div>
{% if filter_form.subevent %}
<div class="col-lg-2 col-md-6 col-md-2 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.subevent %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.date_from %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.date_until %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.time_from %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.time_until %}
</div>
<div class="col-xs-12 one-line-checkboxes">
{% bootstrap_field filter_form.weekday %}
</div>
{% endif %}
</div>
<div class="text-right flip">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %}
</a>
</p>
{% endif %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Quota name" %}
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Products" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th>{% trans "Total capacity" %}
<a href="?{% url_replace request 'ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in quotas %}
<form action="{% url "control:event.items.quotas.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
{% endfor %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<td>
<strong><a href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong>
{% if q.ignore_for_event_availability %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Ignore this quota when determining event availability" %}"></span>
{% endif %}
</td>
<td>
<ul>
{% for item in q.cached_items %}
{% if not item.has_variations %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
{% endif %}
{% endfor %}
{% for v in q.variations.all %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open">
{{ v.item }} {{ v }}</a></li>
{% endfor %}
</ul>
</td>
{% if request.event.has_subevents %}
<td>
{{ q.subevent.name }} {{ q.subevent.get_date_range_display_with_times }}
</td>
{% if "event.items:write" in request.eventpermset %}
<th>
<label aria-label="{% trans "select all rows for batch-operation" %}" class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
</th>
{% endif %}
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td>
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td>
<td class="text-right flip">
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ q.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
<th>{% trans "Quota name" %}
<a href="?{% url_replace request 'filter-ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Products" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'filter-ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th>{% trans "Total capacity" %}
<a href="?{% url_replace request 'filter-ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if "event.items:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all" data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="6">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for q in quotas %}
<tr>
{% if "event.items:write" in request.eventpermset %}
<td>
<label aria-label="{% trans "select row for batch-operation" %}" class="batch-select-label"><input type="checkbox" name="quota" class="batch-select-checkbox" value="{{ q.pk }}"/></label>
</td>
{% endif %}
<td>
<strong><a href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong>
{% if q.ignore_for_event_availability %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Ignore this quota when determining event availability" %}"></span>
{% endif %}
</td>
<td>
<ul>
{% for item in q.cached_items %}
{% if not item.has_variations %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
{% endif %}
{% endfor %}
{% for v in q.variations.all %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open">
{{ v.item }} {{ v }}</a></li>
{% endfor %}
</ul>
</td>
{% if request.event.has_subevents %}
<td>
{{ q.subevent.name }} {{ q.subevent.get_date_range_display_with_times }}
</td>
{% endif %}
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td>
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td>
<td class="text-right flip">
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ q.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if "event.items:write" in request.eventpermset %}
<div class="batch-select-actions">
<button type="submit" class="btn btn-danger btn-save" name="action" value="delete">
<i class="fa fa-trash"></i>{% trans "Delete selected" %}
</button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
formaction="{% url "control:event.items.quotas.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>
</div>
{% endif %}
</form>
{% endif %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}
@@ -134,6 +134,39 @@
</div>
{% endif %}
{% if invoice_qualified and order.invoice_dirty %}
<div class="alert alert-warning">
<p>
{% blocktrans trimmed %}
This order was changed after the last invoice was generated. A new invoice was not generated yet, because invoices are configured to be generated on payment or if required by the payment method.
A new invoice will be generated once the customer pays the invoice or selects a payment method that requires an invoice.
{% endblocktrans %}
</p>
{% if "event.orders:write" in request.eventpermset %}
<p>
{% if uncancelled_invoice %}
<form action="{% url "control:event.order.reissueinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=uncancelled_invoice.pk %}"
method="post">
{% csrf_token %}
<button class="btn btn-default" type="submit">
{% blocktrans trimmed %}
Reissue invoice
{% endblocktrans %}
</button>
</form>
{% elif can_generate_invoice %}
<form method="post" action="{% url "control:event.order.geninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% csrf_token %}
<button class="btn btn-default">
{% trans "Generate invoice" %}
</button>
</form>
{% endif %}
</p>
{% endif %}
</div>
{% endif %}
<div class="row">
<div class="col-xs-12 col-lg-10">
{% for cr in order.cancellation_requests.all %}
@@ -471,7 +504,9 @@
{% endif %}
{% if line.subevent %}
<br/>
<span class="fa fa-calendar fa-fw"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display_with_times }}
<span class="fa fa-calendar fa-fw"></span>
<a href="{% url "control:event.subevent" organizer=request.event.organizer.slug event=request.event.slug subevent=line.subevent_id %}">{{ line.subevent.name }}</a>
&middot; {{ line.subevent.get_date_range_display_with_times }}
{% endif %}
{% if line.used_membership %}
<br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span>
@@ -551,7 +586,7 @@
<span class="fa fa-print"></span>
{{ pl.datetime|date:"SHORT_DATETIME_FORMAT" }}
{{ pl.get_type_display }}
({{ pl.source }}{% if pl.device %}, #{{ pl.device.device_id }}{% endif %})
({{ pl.source }}{% if pl.device %}, {{ pl.device.name }} - #{{ pl.device.device_id }}{% endif %})
{% if not pl.successful %}<span class="fa fa-warning fa-fw"></span>{% endif %}
<br>
{% endfor %}
@@ -1043,7 +1078,7 @@
<dt>{% trans "VAT ID" %}</dt>
<dd>
{{ order.invoice_address.vat_id }}
{% if order.invoice_address.vat_id_validated %}
{% if order.invoice_address.vat_id and order.invoice_address.vat_id_validated %}
<span class="fa fa-check" data-toggle="tooltip" title="{% blocktrans trimmed %}Valid EU VAT ID{% endblocktrans %}"></span>
{% elif order.invoice_address.vat_id %}
<form class="form-inline helper-display-inline" method="post"
@@ -23,9 +23,9 @@
<legend>{% trans "How should the refund be sent?" %}</legend>
<p>
{% blocktrans trimmed %}
Any payments that you selected for automatical refunds will be immediately communicate the refund
request to the respective payment provider. Manual refunds will be created as pending refunds, you
can then later mark them as done once you actually transferred the money back to the customer.
Any payments you selected for automatic refunds will have the refund request sent immediately to the
respective payment provider. Manual refunds will be created as pending refunds, which you can later
mark as done once you have actually transferred the money back to the customer.
{% endblocktrans %}
</p>
@@ -108,7 +108,7 @@
</a>
</p>
{% endif %}
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post">
<form action="#will-be-overridden" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
@@ -19,9 +19,7 @@
{% endif %}
</h1>
<script type="application/json" id="editor-data">
{{ layout|safe }}
</script>
{{ layout|json_script:"editor-data" }}
<div class="row">
<div class="col-md-9">
<div class="panel panel-default panel-pdf-editor">
@@ -379,6 +379,8 @@
<i class="fa fa-calendar"></i> {% trans "Add many time slots" %}</button>
</p>
</div>
<hr />
{% bootstrap_field form.skip_if_overlap layout="control" horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
</fieldset>
<fieldset>
<legend>{% trans "General information" %}</legend>
+2
View File
@@ -349,6 +349,8 @@ urlpatterns = [
name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
re_path(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'),
re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'),
re_path(r'^quotas/(?P<quota>\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'),
+16 -13
View File
@@ -234,13 +234,21 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
self.request.event.log_action('pretix.event.footerlinks.changed', user=self.request.user, data={
'data': self.footer_links_formset.cleaned_data
})
if form.has_changed():
self.request.event.log_action('pretix.event.changed', user=self.request.user, data={
k: (form.cleaned_data.get(k).name
if isinstance(form.cleaned_data.get(k), File)
else form.cleaned_data.get(k))
for k in form.changed_data
})
change_data = {
k: (form.cleaned_data.get(k).name
if isinstance(form.cleaned_data.get(k), File)
else form.cleaned_data.get(k))
for k in form.changed_data
}
meta_changed = {}
for f in self.meta_forms:
if f.has_changed():
meta_changed[f.property.name] = f.cleaned_data["value"]
if meta_changed:
change_data['meta_data'] = meta_changed
if change_data:
self.request.event.log_action('pretix.event.changed', user=self.request.user, data=change_data)
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk})
messages.success(self.request, _('Your changes have been saved.'))
@@ -763,12 +771,7 @@ class InvoicePreview(EventPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs):
fname, ftype, fcontent = build_preview_invoice_pdf(request.event)
resp = HttpResponse(fcontent, content_type=ftype)
if settings.DEBUG:
# attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
resp._csp_ignore = True
else:
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(fname)
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
return resp
@@ -300,5 +300,4 @@ class SysReportView(AdministratorPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data)
resp['Content-Type'] = mime
resp['Content-Disposition'] = 'inline; filename="{}"'.format(name)
resp._csp_ignore = True
return resp
+201 -34
View File
@@ -41,21 +41,22 @@ from json.decoder import JSONDecodeError
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.db import transaction
from django.db import models, transaction
from django.db.models import (
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q,
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q, Subquery, Value,
)
from django.db.models.functions import Cast, Concat
from django.forms.models import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
)
from django.shortcuts import redirect
from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import ListView
from django.views.generic import FormView, ListView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
@@ -65,7 +66,7 @@ from pretix.api.serializers.item import (
)
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher,
)
@@ -74,12 +75,15 @@ 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.filter import (
QuestionAnswerFilterForm, QuotaFilterForm,
)
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaBulkEditForm,
QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -87,6 +91,7 @@ from pretix.control.permissions import (
from pretix.control.signals import item_forms, item_formsets
from pretix.helpers.models import modelcopy
from ...helpers import GroupConcat
from ...helpers.compat import CompatDeleteView
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -831,13 +836,38 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
class QuotaList(PaginationMixin, ListView):
class QuotaQueryMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
return self.request.POST
return self.request.GET
def get_queryset(self):
qs = self.request.event.quotas
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
if 'quota' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('quota')
)
return qs
@cached_property
def filter_form(self):
return QuotaFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
model = Quota
context_object_name = 'quotas'
template_name = 'pretixcontrol/items/quotas.html'
def get_queryset(self):
qs = self.request.event.quotas.prefetch_related(
return super().get_queryset().prefetch_related(
Prefetch(
"items",
queryset=Item.objects.annotate(
@@ -852,28 +882,10 @@ class QuotaList(PaginationMixin, ListView):
queryset=self.request.event.subevents.all()
)
)
if self.request.GET.get("subevent", "") != "":
s = self.request.GET.get("subevent", "")
qs = qs.filter(subevent_id=s)
valid_orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
if self.request.GET.get("ordering", "-date") in valid_orders:
qs = qs.order_by(*valid_orders[self.request.GET.get("ordering", "-date")])
else:
qs = qs.order_by('name', 'subevent__date_from', 'pk')
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['filter_form'] = self.filter_form
qa = QuotaAvailability()
qa.queue(*ctx['quotas'])
@@ -884,6 +896,165 @@ class QuotaList(PaginationMixin, ListView):
return ctx
class QuotaBulkAction(QuotaQueryMixin, EventPermissionRequiredMixin, View):
permission = 'event.items:write'
@transaction.atomic
def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'delete':
return render(request, 'pretixcontrol/items/quota_delete_bulk.html', {
'allowed': self.get_queryset().select_related("subevent"),
})
elif request.POST.get('action') == 'delete_confirm':
log_entries = []
to_delete = []
for obj in self.get_queryset():
log_entries.append(obj.log_action('pretix.event.quota.deleted', user=self.request.user, save=False))
to_delete.append(obj.pk)
if to_delete:
LogEntry.bulk_create_and_postprocess(log_entries)
Quota.objects.filter(pk__in=to_delete).delete()
messages.success(request, _('The selected quotas have been deleted or disabled.'))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
class QuotaBulkUpdateView(QuotaQueryMixin, EventPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/items/quota_bulk_edit.html'
permission = 'event.items:write'
context_object_name = 'quota'
form_class = QuotaBulkEditForm
def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by()
def get(self, request, *args, **kwargs):
return HttpResponse(status=405)
@cached_property
def is_submitted(self):
# Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
# called with POST method, even if just to pass the selection of objects to work on, so we want to modify
# that behaviour
return '_bulk' in self.request.POST
def get_form_kwargs(self):
initial = {}
mixed_values = set()
qs = self.get_queryset().annotate(
items_list=Subquery(
Quota.items.through.objects.filter(
quota_id=OuterRef('pk'),
item__variations__isnull=True,
).order_by().values('quota_id').annotate(
g=GroupConcat('item_id', separator=',', ordered=True)
).values('g')
),
vars_list=Subquery(
Quota.variations.through.objects.filter(
quota_id=OuterRef('pk')
).order_by().values('quota_id').annotate(
g=GroupConcat(
Concat(
Cast(F('itemvariation__item_id'), output_field=models.TextField()),
Value('-', output_field=models.TextField()),
Cast(F('itemvariation_id'), output_field=models.TextField()),
),
separator=',',
ordered=True
)
).values('g')
),
)
fields = {
'name': 'name',
'size': 'size',
'subevent': 'subevent',
'close_when_sold_out': 'close_when_sold_out',
'release_after_exit': 'release_after_exit',
'ignore_for_event_availability': 'ignore_for_event_availability',
}
for k, f in fields.items():
existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
if len(existing_values) == 1:
initial[k] = existing_values[0][f]
elif len(existing_values) > 1:
mixed_values.add(k)
initial[k] = None
item_values = list(qs.order_by("items_list").values("items_list").annotate(c=Count('*')))
var_values = list(qs.order_by("vars_list").values("vars_list").annotate(c=Count('*')))
if len(item_values) > 1 or len(var_values) > 1:
mixed_values.add("itemvars")
else:
initial["itemvars"] = [iv for iv in (item_values[0]["items_list"] or "").split(",") + (var_values[0]["vars_list"] or "").split(",") if iv]
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['prefix'] = 'bulkedit'
kwargs['initial'] = initial
kwargs['queryset'] = self.get_queryset()
kwargs['mixed_values'] = mixed_values
if not self.is_submitted:
kwargs['data'] = None
kwargs['files'] = None
return kwargs
def get_success_url(self):
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
@transaction.atomic()
def form_valid(self, form):
log_entries = []
# Main form
form.save()
data = {
k: v
for k, v in form.cleaned_data.items()
if k in form.changed_data
}
data['_raw_bulk_data'] = self.request.POST.dict()
for obj in self.get_queryset():
log_entries.append(
obj.log_action('pretix.event.quota.changed', data=data, user=self.request.user, save=False)
)
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['quotas'] = self.get_queryset()
ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
return ctx
def post(self, request, *args, **kwargs):
form = self.get_form()
is_valid = (
self.is_submitted and
form.is_valid()
)
if is_valid:
return self.form_valid(form)
else:
if self.is_submitted:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.form_invalid(form)
class QuotaCreate(EventPermissionRequiredMixin, CreateView):
model = Quota
form_class = QuotaForm
@@ -1447,12 +1618,8 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
meta_changed = {}
for f in self.meta_forms:
meta_changed.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
})
if f.has_changed():
meta_changed[f.property.name] = f.cleaned_data["value"]
if meta_changed:
change_data['meta_data'] = meta_changed
+21 -31
View File
@@ -79,9 +79,9 @@ from pretix.base.email import get_email_context
from pretix.base.exporter import MultiSheetListExporter
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedFile, CachedTicket, Checkin, Invoice,
InvoiceAddress, Item, ItemVariation, LogEntry, Order, QuestionAnswer,
Quota, ScheduledEventExport, generate_secret,
CachedFile, CachedTicket, Checkin, Invoice, InvoiceAddress, Item,
ItemVariation, LogEntry, Order, QuestionAnswer, Quota,
ScheduledEventExport, generate_secret,
)
from pretix.base.models.orders import (
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
@@ -554,6 +554,9 @@ class OrderDetail(OrderView):
ctx['download_buttons'] = self.download_buttons
ctx['payment_refund_sum'] = self.order.payment_refund_sum
ctx['pending_sum'] = self.order.pending_sum
ctx['uncancelled_invoice'] = self.order.invoices.exclude(
Exists(self.order.invoices.filter(refers=OuterRef('pk'), is_cancellation=True))
).exclude(is_cancellation=True).first()
return ctx
@@ -710,34 +713,21 @@ class OrderDownload(AsyncAction, OrderView):
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
return FileResponse(
value.file.file,
filename='{}-{}-{}-{}{}'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
),
content_type=value.type
)
return resp
elif isinstance(value, CachedCombinedTicket):
if value.type == 'text/uri-list':
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
)
return resp
else:
return redirect(self.get_self_url())
def get_last_ct(self):
if 'position' in self.kwargs:
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
else:
ct = CachedCombinedTicket.objects.filter(
order=self.order, provider=self.output.identifier, file__isnull=False
).last()
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
if not ct or not ct.file:
return None
return ct
@@ -1831,15 +1821,15 @@ class InvoiceDownload(EventPermissionRequiredMixin, View):
return redirect(self.get_order_url())
try:
resp = FileResponse(self.invoice.file.file, content_type='application/pdf')
return FileResponse(
self.invoice.file.file,
filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number)),
content_type='application/pdf'
)
except FileNotFoundError:
invoice_pdf_task.apply(args=(self.invoice.pk,))
return self.get(request, *args, **kwargs)
resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number))
resp._csp_ignore = True # Some browser's PDF readers do not work with CSP
return resp
class OrderExtend(OrderView):
permission = 'event.orders:write'
+16 -20
View File
@@ -102,7 +102,7 @@ from pretix.base.models.organizer import (
from pretix.base.payment import PaymentException
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
PLUGIN_LEVEL_ORGANIZER, plugin_is_available,
)
from pretix.base.services.export import (
init_organizer_exporters, multiexport, scheduled_organizer_export,
@@ -597,6 +597,13 @@ class OrganizerCreate(CreateView):
})
def available_plugins(organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Organizer
context_object_name = 'organizer'
@@ -606,12 +613,6 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def get_object(self, queryset=None) -> Organizer:
return self.request.organizer
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def prepare_links(self, pluginmeta, key):
links = getattr(pluginmeta, key, [])
try:
@@ -637,7 +638,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER
context = super().get_context_data(*args, **kwargs)
plugins = list(self.available_plugins(self.object))
plugins = list(available_plugins(self.object))
active_counter = Counter()
events_total = 0
@@ -685,7 +686,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
self.object = self.get_object()
plugins_available = {
p.module: p for p in self.available_plugins(self.object)
p.module: p for p in available_plugins(self.object)
}
choose_events_next = False
with transaction.atomic():
@@ -786,12 +787,6 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
}
return kwargs
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def get_context_data(self, **kwargs):
return super().get_context_data(
plugin=self.plugin,
@@ -799,12 +794,10 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
)
def dispatch(self, request, *args, **kwargs):
plugins_available = {
p.module: p for p in self.available_plugins(self.request.organizer)
}
if kwargs["plugin"] not in plugins_available:
try:
self.plugin = next(p for p in available_plugins(self.request.organizer) if p.module == kwargs["plugin"])
except StopIteration:
raise Http404(_("Unknown plugin."))
self.plugin = plugins_available[kwargs["plugin"]]
level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_ORGANIZER:
raise Http404(_("This plugin can only be enabled for the entire organizer account."))
@@ -835,6 +828,9 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
logentries_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_enable):
if not plugin_is_available(self.plugin, organizer=self.request.organizer, event=e):
messages.warning(self.request, _("This plugin cannot be activated for event {}.").format(e.name))
continue
logentries_to_save.append(
e.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False)
)
+3 -9
View File
@@ -263,12 +263,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1]
if settings.DEBUG:
# attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
resp._csp_ignore = True
else:
resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype)
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
return resp
elif "data" in request.POST:
if cf:
@@ -289,7 +284,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
ctx['pdf'] = self.get_current_background()
ctx['variables'] = self.get_variables()
ctx['images'] = self.get_images()
ctx['layout'] = json.dumps(self.get_current_layout())
ctx['layout'] = self.get_current_layout()
ctx['title'] = self.title
ctx['locales'] = [p for p in settings.LANGUAGES if p[0] in self.request.event.settings.locales]
ctx['maxfilesize'] = self.maxfilesize
@@ -309,6 +304,5 @@ class FontsCSSView(TemplateView):
class PdfView(TemplateView):
def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
resp = FileResponse(cf.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf')
return resp
+62 -13
View File
@@ -531,6 +531,7 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
@transaction.atomic
def form_valid(self, form):
self.object = form.save()
self.save_formset(self.object)
self.save_cl_formset(self.object)
self.save_meta()
@@ -540,25 +541,36 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
# TODO: LogEntry?
messages.success(self.request, _('Your changes have been saved.'))
if form.has_changed() or any(f.has_changed() for f in self.plugin_forms):
data = {
k: form.cleaned_data.get(k) for k in form.changed_data
}
for f in self.plugin_forms:
data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
})
change_data = {
k: (form.cleaned_data.get(k).name
if isinstance(form.cleaned_data.get(k), File)
else form.cleaned_data.get(k))
for k in form.changed_data
}
meta_changed = {}
for f in self.meta_forms:
if f.has_changed():
meta_changed[f.property.name] = f.cleaned_data["value"]
if meta_changed:
change_data['meta_data'] = meta_changed
for f in self.plugin_forms:
change_data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
})
if change_data:
self.object.log_action(
'pretix.subevent.changed', user=self.request.user, data=data
'pretix.subevent.changed', user=self.request.user, data=change_data
)
for f in self.plugin_forms:
f.subevent = self.object
f.save()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk})
return super().form_valid(form)
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:event.subevents', kwargs={
@@ -628,6 +640,14 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi
else f.cleaned_data.get(k))
for k in f.cleaned_data
})
meta_changed = {}
for f in self.meta_forms:
if f.has_changed():
meta_changed[f.property.name] = f.cleaned_data["value"]
if meta_changed:
data['meta_data'] = meta_changed
form.instance.log_action('pretix.subevent.added', data=dict(data), user=self.request.user)
self.save_formset(form.instance)
@@ -917,6 +937,35 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
if len(subevents) > 100_000:
raise ValidationError(_('Please do not create more than 100.000 dates at once.'))
if form.cleaned_data.get("skip_if_overlap") and subevents:
def overlaps(a_from, a_to, b_from, b_to):
if a_from == b_from:
return True
if a_from > b_from:
# a starts after b
# check if it starts before b ends
return b_to and a_from < b_to
# a starts before b
# check if it ends before b starts
return a_to and a_to > b_from
date_min = min(se.date_from for se in subevents)
date_max = max(se.date_to or se.date_from for se in subevents)
dates_existing = list(self.request.event.subevents.annotate(
date_fromto=Coalesce('date_to', 'date_from'),
).filter(
date_from__lte=date_max,
date_fromto__gte=date_min,
).values('date_from', 'date_to'))
subevents = [
se for se in subevents if not any(
overlaps(se.date_from, se.date_to, other['date_from'], other['date_to'])
for other in dates_existing
)
]
if not subevents:
raise ValidationError(_('All dates would be skipped because they conflict with existing dates.'))
for i, se in enumerate(subevents):
se.save(clear_cache=False)
if i % 100 == 0:
+3 -3
View File
@@ -316,7 +316,7 @@ def nav_context_list(request):
page = 1
qs_events = request.user.get_events_with_any_permission(request).filter(
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query)
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) | Q(domain__domainname__iexact=query)
).annotate(
min_from=Min('subevents__date_from'),
max_from=Max('subevents__date_from'),
@@ -331,7 +331,7 @@ def nav_context_list(request):
else:
qs_orga = Organizer.objects.filter(pk__in=request.user.teams.values_list('organizer', flat=True))
if query:
qs_orga = qs_orga.filter(Q(name__icontains=query) | Q(slug__icontains=query))
qs_orga = qs_orga.filter(Q(name__icontains=query) | Q(slug__icontains=query) | Q(domains__domainname__iexact=query))
qs_orga = qs_orga.annotate(
n_events=Count("events")
).order_by("-n_events")
@@ -619,7 +619,7 @@ def checkinlist_select2(request, **kwargs):
qs = request.event.checkin_lists.select_related('subevent').filter(
qf
).order_by('name')
).order_by('subevent__date_from', 'name', 'pk')
total = qs.count()
pagesize = 20
+5
View File
@@ -29,3 +29,8 @@ class PretixHelpersConfig(AppConfig):
def ready(self):
from .monkeypatching import monkeypatch_all_at_ready
monkeypatch_all_at_ready()
# Ensure reportlab does not make any calls to the internet or the local disk
from reportlab import rl_config
rl_config.trustedHosts = []
rl_config.trustedSchemes = ['data']
-63
View File
@@ -1,63 +0,0 @@
#
# 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/>.
#
import os
import re
import shlex
from compressor.exceptions import FilterError
from compressor.filters import CompilerFilter
from django.conf import settings
class VueCompiler(CompilerFilter):
# Based on work (c) Laura Klünder in https://github.com/codingcatgirl/django-vue-rollup
# Released under Apache License 2.0
def __init__(self, content, attrs, **kwargs):
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'static', 'npm_dir')
node_path = os.path.join(settings.STATIC_ROOT, 'node_prefix', 'node_modules')
self.rollup_bin = os.path.join(node_path, 'rollup', 'dist', 'bin', 'rollup')
rollup_config = os.path.join(config_dir, 'rollup.config.js')
if not os.path.exists(self.rollup_bin) and not settings.DEBUG:
raise FilterError("Rollup not installed or pretix not built properly, please run 'make npminstall' in source root.")
command = (
' '.join((
'NODE_PATH=' + shlex.quote(node_path),
shlex.quote(self.rollup_bin),
'-c',
shlex.quote(rollup_config))
) +
' --input {infile} -n {export_name} --file {outfile}'
)
super().__init__(content, command=command, **kwargs)
def input(self, **kwargs):
if self.filename is None:
raise FilterError('VueCompiler can only compile files, not inline code.')
if not os.path.exists(self.rollup_bin):
raise FilterError("Rollup not installed, please run 'make npminstall' in source root.")
self.options += (('export_name', re.sub(
r'^([a-z])|[^a-z0-9A-Z]+([a-zA-Z0-9])?',
lambda s: s.group(0)[-1].upper(),
os.path.basename(self.filename).split('.')[0]
)),)
return super().input(**kwargs)
+6 -1
View File
@@ -117,12 +117,17 @@ class GroupConcat(Aggregate):
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s' ORDER BY %(field)s::text ASC)"
else:
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s')"
return super().as_sql(
template, params = super().as_sql(
compiler, connection,
function='string_agg',
template=template,
**extra_context,
)
if self.ordered:
# ordered statement requires field parameters twice
params = params + params
return template, params
class ReplicaRouter:
+157
View File
@@ -19,12 +19,31 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import ipaddress
import socket
import sys
import types
from datetime import datetime
from http import cookies
from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from PIL import Image
from requests.adapters import HTTPAdapter
from urllib3.connection import HTTPConnection, HTTPSConnection
from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
from urllib3.exceptions import (
ConnectTimeoutError, HTTPError, LocationParseError, NameResolutionError,
NewConnectionError,
)
from urllib3.util.connection import (
_TYPE_SOCKET_OPTIONS, _set_socket_options, allowed_gai_family,
)
from urllib3.util.timeout import _DEFAULT_TIMEOUT
from pretix.helpers.reportlab import ThumbnailingImageReader
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
def monkeypatch_vobject_performance():
@@ -89,14 +108,152 @@ def monkeypatch_requests_timeout():
HTTPAdapter.send = httpadapter_send
def monkeypatch_urllib3_ssrf_protection():
"""
pretix allows HTTP requests to untrusted URLs, e.g. through webhooks or external API URLs. This is dangerous since
it can allow access to private networks that should not be reachable by users ("server-side request forgery", SSRF).
Validating URLs at submission is not sufficient, since with DNS rebinding an attacker can make a domain name pass
validation and then resolve to a private IP address on actual execution. Unfortunately, there seems no clean solution
to this in Python land, so we monkeypatch urllib3's connection management to check the IP address to be external
*after* the DNS resolution.
This does not work when a global http(s) proxy is used, but in that scenario the proxy can perform the validation.
"""
if getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
# Settings are not supposed to change during runtime, so we can optimize performance and complexity by skipping
# this if not needed.
return
def create_connection(
address: tuple[str, int],
timeout=_DEFAULT_TIMEOUT,
source_address: tuple[str, int] | None = None,
socket_options: _TYPE_SOCKET_OPTIONS | None = None,
) -> socket.socket:
# This is copied from urllib3.util.connection v2.3.0
host, port = address
if host.startswith("["):
host = host.strip("[]")
err = None
# Using the value from allowed_gai_family() in the context of getaddrinfo lets
# us select whether to work with IPv4 DNS records, IPv6 records, or both.
# The original create_connection function always returns all records.
family = allowed_gai_family()
try:
host.encode("idna")
except UnicodeError:
raise LocationParseError(f"'{host}', label empty or too long") from None
for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM):
af, socktype, proto, canonname, sa = res
if not getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
if ip_addr.is_multicast:
raise HTTPError(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise HTTPError(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise HTTPError(f"Request to private address {sa[0]} blocked")
if ip_addr in _cgnat_net:
raise HTTPError(f"Request to RFC 6598 address {sa[0]} blocked")
sock = None
try:
sock = socket.socket(af, socktype, proto)
# If provided, set socket level options before connecting.
_set_socket_options(sock, socket_options)
if timeout is not _DEFAULT_TIMEOUT:
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sa)
# Break explicitly a reference cycle
err = None
return sock
except OSError as _:
err = _
if sock is not None:
sock.close()
if err is not None:
try:
raise err
finally:
# Break explicitly a reference cycle
err = None
else:
raise OSError("getaddrinfo returns an empty list")
class ProtectionMixin:
def _new_conn(self) -> socket.socket:
# This is 1:1 the version from urllib3.connection.HTTPConnection._new_conn v2.3.0
# just with a call to our own create_connection
try:
sock = create_connection(
(self._dns_host, self.port),
self.timeout,
source_address=self.source_address,
socket_options=self.socket_options,
)
except socket.gaierror as e:
raise NameResolutionError(self.host, self, e) from e
except socket.timeout as e:
raise ConnectTimeoutError(
self,
f"Connection to {self.host} timed out. (connect timeout={self.timeout})",
) from e
except OSError as e:
raise NewConnectionError(
self, f"Failed to establish a new connection: {e}"
) from e
sys.audit("http.client.connect", self, self.host, self.port)
return sock
class ProtectedHTTPConnection(ProtectionMixin, HTTPConnection):
pass
class ProtectedHTTPSConnection(ProtectionMixin, HTTPSConnection):
pass
HTTPConnectionPool.ConnectionCls = ProtectedHTTPConnection
HTTPSConnectionPool.ConnectionCls = ProtectedHTTPSConnection
def monkeypatch_cookie_morsel():
# See https://code.djangoproject.com/ticket/34613
cookies.Morsel._flags.add("partitioned")
cookies.Morsel._reserved.setdefault("partitioned", "Partitioned")
def monkeypatch_reportlab_imagereader():
from reportlab.lib import utils
old_init = utils.ImageReader.__init__
def new_init(self, fileName, ident=None): # noqa
if not isinstance(fileName, Image.Image) and not hasattr(fileName, 'read') and not hasattr(fileName, 'str'):
if not isinstance(self, ThumbnailingImageReader):
# ThumbnailingImageReader is only used by us explicitly and not by using <img> in html, so it is safe
raise SuspiciousFileOperation("reportlab should not be reading images from disk")
return types.MethodType(old_init, self)(
fileName, ident
)
utils.ImageReader.__init__ = new_init
def monkeypatch_all_at_ready():
monkeypatch_vobject_performance()
monkeypatch_pillow_safer()
monkeypatch_requests_timeout()
monkeypatch_urllib3_ssrf_protection()
monkeypatch_cookie_morsel()
monkeypatch_reportlab_imagereader()
+39
View File
@@ -20,14 +20,19 @@
# <https://www.gnu.org/licenses/>.
#
import logging
import re
import unicodedata
from arabic_reshaper import ArabicReshaper
from bidi import get_display
from django.conf import settings
from django.utils.functional import SimpleLazyObject
from django.utils.html import escape
from PIL import Image
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph
from pretix.presale.style import get_fonts
@@ -70,6 +75,20 @@ reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
}))
def normalize_text(text: str) -> str:
# reportlab does not support unicode combination characters
# It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFKC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "\n".join(get_display(reshaper.reshape(l)) for l in re.split("\n", text))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
class FontFallbackParagraph(Paragraph):
def __init__(self, text, style=None, *args, **kwargs):
if style is None:
@@ -87,6 +106,8 @@ class FontFallbackParagraph(Paragraph):
if not text:
return True
font = pdfmetrics.getFont(font_name)
if not isinstance(font, TTFont):
return True
return all(
ord(c) in font.face.charToGlyph or not c.isprintable()
for c in text
@@ -102,6 +123,24 @@ class FontFallbackParagraph(Paragraph):
return family
class PlainTextParagraph(FontFallbackParagraph):
def __init__(self, text, style=None, linebreaks=True, *args, **kwargs):
if not isinstance(text, str):
if hasattr(text, '__html__'):
raise ValueError("It is contradictory to pass escaped content to PlainTextParagraph")
text = str(text)
# Normalize unicode and apply reshaping
text = normalize_text(text)
# Escape any HTML in the text
text = escape(text)
if linebreaks:
text = text.strip().replace("\n", "<br />\n")
super().__init__(text, style, *args, **kwargs)
def register_ttf_font_if_new(name, path):
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
@@ -0,0 +1,33 @@
#
# 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/>.
#
import logging
from django import template
from django.utils.html import format_html
register = template.Library()
logger = logging.getLogger(__name__)
@register.filter
def wrap_in(content, tag_name):
return format_html(f'<{tag_name}>{{}}</{tag_name}>', content)
+5 -5
View File
@@ -173,6 +173,7 @@ def create_thumbnail(source, size, formats=None):
# filesystem path, this only works because _open() uses safe_join, which accepts absolute paths if they match the
# expected base dir. For NanoCDN Files, this works because source.name is set to the storage path.
source_rb = default_storage.open(source_name, mode='rb')
source_ext = os.path.splitext(source_name)[1].lower()
image = Image.open(BytesIO(source_rb.read()), formats=formats or settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
try:
@@ -183,11 +184,14 @@ def create_thumbnail(source, size, formats=None):
frames = []
durations = []
for f in ImageSequence.Iterator(image):
if f.mode in ("P", "PA") and source_ext == '.png':
f = f.convert('RGBA')
if f.mode not in ("1", "L", "RGB", "RGBA"):
f = f.convert('RGB')
durations.append(f.info.get("duration", 1000))
frames.append(resize_image(f, size))
image_out = frames[0]
save_kwargs = {}
source_ext = os.path.splitext(source_name)[1].lower()
if source_ext == '.jpg' or source_ext == '.jpeg':
# Yields better file sizes for photos
@@ -211,10 +215,6 @@ def create_thumbnail(source, size, formats=None):
checksum = hashlib.md5(image.tobytes()).hexdigest()
name = checksum + '.' + size.replace('^', 'c') + '.' + target_ext
buffer = BytesIO()
if image_out.mode == "P" and source_ext == '.png':
image_out = image_out.convert('RGBA')
if image_out.mode not in ("1", "L", "RGB", "RGBA"):
image_out = image_out.convert('RGB')
image_out.save(fp=buffer, format=target_ext.upper(), quality=quality, **save_kwargs)
imgfile = ContentFile(buffer.getvalue())

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