Compare commits

..
Author SHA1 Message Date
Raphael Michel 4142a2b690 Orders API: Fix race condition in voucher redemption (Z#23230391)
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-08 15:07:34 +02:00
Raphael MichelandRaphael Michel 221cbd15ab [SECURITY] API: Add missing event filter for check-ins 2026-04-08 13:57:55 +02:00
Lukas BockstallerandGitHub 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
Richard SchreiberandGitHub c037fd865b Fix multi-product order edit with seats (#6063) 2026-04-08 11:02:58 +02:00
Kara Engelhardtandpajowu 12171e0665 Fix copy-and-paste errors 2026-04-07 14:39:33 +02:00
Kara Engelhardtandpajowu 444963e952 tests: Remove on_commit monkeypatch 2026-04-07 14:39:33 +02:00
Kara Engelhardtandpajowu a57810cf41 tests: replace broken monkeypatching with TransactionTestCase 2026-04-07 14:39:33 +02:00
Kara Engelhardtandpajowu 2e2e57d231 Fix typo in test detection, improve check
A non-empty string is truthy, making the the for-loop useless, as the first item in inspect.stack() is always the for-loop itself, which then lead to the function returning immediately.
This commit
* fixes this typo
* changes the loop to ignore the first element of instpect.stack() (which is the loop itself)
* ignores django-internal code

This should create something similar to what I suspect the code was intended to do originally.
2026-04-07 14:39:33 +02:00
Kara Engelhardtandpajowu fc7e8ea67a Log new properties when changing device 2026-04-07 13:28:38 +02:00
Raphael Michel 23d1673403 Fix typo 2026-04-02 21:43:36 +02:00
Raphael Michel 92d1830f3b Exporters: Pass state about staff_session 2026-04-02 21:03:42 +02:00
Raphael MichelandGitHub d411c36414 Exporters: Give access to authentication infos and allow empty permissions (#5979)
* Exporters: Give access to authentication infos

* Allow exporters to have empty permission

* Use a protocol
2026-04-02 15:44:36 +02:00
Raphael MichelandGitHub 84e12fea32 Dockerfile: Use Python 3.13 (#6028) 2026-04-02 13:18:04 +02:00
Kara Engelhardtandpajowu b6518449d6 Add placeholder for checked in addons (Z#23230009) 2026-04-02 12:06:00 +02:00
Ruud HendrickxandRaphael Michel 50c99e1239 Translations: Update Dutch (Belgium)
Currently translated at 82.8% (5210 of 6287 strings)

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

powered by weblate
2026-04-02 12:01:26 +02:00
dependabot[bot]andRaphael Michel e70452ee47 Update pillow requirement from ==12.1.* to ==12.2.*
Updates the requirements on [pillow](https://github.com/python-pillow/Pillow) to permit the latest version.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.1.0...12.2.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 12:01:19 +02:00
Raphael MichelandGitHub 666b496ab4 Allow to configure a readonly DB connection (#5978) 2026-04-01 13:46:52 +02:00
Richard SchreiberandGitHub 8bd0665f37 Fix password-manager username not saved on customer account creation (#6043)
* Fix password-manager username not saved on customer account creation

* Fix tests/make email not required
2026-04-01 12:00:03 +02:00
Raphael MichelandGitHub ed1459b1dd Order change form: Allow to add multiple identical positions (Z#23227479) (#6044)
* Order change form: Allow to add multiple identical positions (Z#23227479)

* New implementation
2026-04-01 11:54:48 +02:00
Raphael MichelandGitHub 8c251029b9 Fix useless cart sessions being created (#6045)
* Do not create useless cart session accessing invoice address

* Skip useless code paths in CartMixin

* Do not create cart session on view with active session

* Create regression tests
2026-04-01 09:29:14 +02:00
dependabot[bot]andRaphael Michel 531f697b9a Update redis requirement from ==7.1.* to ==7.4.*
Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v7.1.0...v7.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 09:20:14 +02:00
Ruud HendrickxandRaphael Michel 719ad7104d Translations: Update Dutch (Belgium)
Currently translated at 82.1% (5164 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
Ruud HendrickxandRaphael Michel dcb0eb765f Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
CVZ-esandRaphael Michel 86b5191e8b 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-01 09:19:49 +02:00
Ruud HendrickxandRaphael Michel b0714886bc Translations: Update Dutch
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
CVZ-esandRaphael Michel 438f70c730 Translations: Update French
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
Ruud HendrickxandRaphael Michel 608b150bf8 Translations: Update Dutch (Belgium)
Currently translated at 79.6% (5007 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
Renne RochaandRaphael Michel c0df7c6142 Translations: Update Portuguese (Brazil)
Currently translated at 95.1% (5980 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
dependabot[bot]andRaphael Michel b2ea172a60 Update sentry-sdk requirement from ==2.56.* to ==2.57.*
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.56.0...2.57.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 09:19:35 +02:00
Raphael Michel a2cef22ea8 Bump version to 2026.4.0.dev0 2026-03-30 15:01:39 +02:00
Raphael Michel 3843448812 Bump version to 2026.3.0 2026-03-30 15:01:30 +02:00
Kara Engelhardtandpajowu 49893ca9df Fix crash in mail_send_task for nonexistant mails 2026-03-30 14:57:56 +02:00
Raphael MichelandRaphael Michel 4eade5070e Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-03-30 14:01:13 +02:00
Raphael MichelandRaphael Michel 32b1997208 Translations: Update German
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-03-30 14:01:13 +02:00
Raphael Michel eaf4a310f6 Translations: Update wordlist 2026-03-30 13:59:37 +02:00
Raphael Michel 8dc0f7c1b2 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-03-30 13:26:02 +02:00
CVZ-esandRaphael Michel dd3e6c4692 Translations: Update Spanish
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-03-30 13:21:45 +02:00
Kara Engelhardtandpajowu c7437336b4 Add length help text to customer password forms
Also cleans up dead code, as `validate_password` always returns None or raises a ValidationError.
2026-03-30 11:25:14 +02:00
luelistaandGitHub 4c0c775baa Improve 2fa type selection UI (#6031) 2026-03-27 13:47:10 +01:00
Linnea ThelanderandMartin Gross 394652a5ff Translations: Update Swedish
Currently translated at 88.0% (5530 of 6283 strings)

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

powered by weblate
2026-03-27 10:05:21 +01:00
Ivano VogheraandMartin Gross 3f50d065ec Translations: Update Italian
Currently translated at 40.0% (2515 of 6283 strings)

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

powered by weblate
2026-03-27 10:05:21 +01:00
Ivano VogheraandMartin Gross 4121061267 Translations: Update Italian
Currently translated at 40.0% (2515 of 6283 strings)

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

powered by weblate
2026-03-26 18:50:24 +01:00
Linnea ThelanderandMartin Gross aed2220139 Translations: Update Swedish
Currently translated at 76.9% (197 of 256 strings)

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

powered by weblate
2026-03-26 18:50:24 +01:00
Linnea ThelanderandMartin Gross 4b2c54d38e Translations: Update Swedish
Currently translated at 88.0% (5530 of 6283 strings)

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

powered by weblate
2026-03-26 18:50:24 +01:00
Ivano VogheraandMartin Gross 0113a3dc1f Translations: Update Italian
Currently translated at 39.9% (2507 of 6283 strings)

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

powered by weblate
2026-03-26 18:50:24 +01:00
Linnea ThelanderandMartin Gross c12a8935f1 Translations: Update Swedish
Currently translated at 87.9% (5529 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Pietro IsottiandMartin Gross a86a6cc2c7 Translations: Update Italian
Currently translated at 39.5% (2486 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Pietro IsottiandMartin Gross fec2b9a2fc Translations: Update Italian
Currently translated at 68.3% (175 of 256 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Pietro IsottiandMartin Gross d847a7e8f8 Translations: Update Italian
Currently translated at 39.2% (2463 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Ruud HendrickxandMartin Gross c58a968196 Translations: Update Dutch (Belgium)
Currently translated at 79.1% (4970 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Renne RochaandMartin Gross 81cbaca162 Translations: Update Portuguese (Brazil)
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Renne RochaandMartin Gross 218df7a49f Translations: Update Portuguese (Brazil)
Currently translated at 95.1% (5979 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Ruud HendrickxandMartin Gross f64343d977 Translations: Update Dutch (Belgium)
Currently translated at 78.7% (4948 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Hijiri UmemotoandMartin Gross b36c7cbef3 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-03-26 11:48:45 +01:00
Hijiri UmemotoandMartin Gross 18b39ba7cd Translations: Update Japanese
Currently translated at 100.0% (6283 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Raphael Michel 1383e967df Hotfix font select in organizer 2026-03-25 15:14:20 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c743e9fd3f Update sentry-sdk requirement from ==2.54.* to ==2.56.* (#6023)
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.54.0...2.56.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.56.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-03-25 13:41:13 +01:00
Raphael MichelandGitHub a71efa6747 Event settings: Workaround for Django 5.2 change (#6025) 2026-03-24 22:00:05 +01:00
Richard Schreiber 4fed47fb9b Fix live_receivers for django 5 2026-03-24 17:14:05 +01:00
Phin WolkwitzandGitHub c143d50290 Update django to 5.2 2026-03-24 16:33:28 +01:00
luelistaandGitHub 88cd715ece Always show Organizers and Events menu entries for staff (#6011) 2026-03-24 11:26:54 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3513de6a45 Update importlib-metadata requirement from ==8.* to ==9.* (#6016)
Updates the requirements on [importlib-metadata](https://github.com/python/importlib_metadata) to permit the latest version.
- [Release notes](https://github.com/python/importlib_metadata/releases)
- [Changelog](https://github.com/python/importlib_metadata/blob/main/NEWS.rst)
- [Commits](https://github.com/python/importlib_metadata/compare/v8.0.0...v9.0.0)

---
updated-dependencies:
- dependency-name: importlib-metadata
  dependency-version: 9.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-03-24 09:09:07 +01:00
Richard SchreiberandGitHub fd6d3934c0 Remove invoice_address_from_vat_id on save if it is not used 2026-03-23 14:33:17 +01:00
137 changed files with 18334 additions and 16697 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: Packaging name: Packaging
strategy: strategy:
matrix: matrix:
python-version: ["3.11"] python-version: ["3.13"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
+4 -4
View File
@@ -24,10 +24,10 @@ jobs:
name: Check gettext syntax name: Check gettext syntax
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.13
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.13
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -49,10 +49,10 @@ jobs:
name: Spellcheck name: Spellcheck
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.13
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.13
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
+6 -6
View File
@@ -24,10 +24,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.13
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.13
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -44,10 +44,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.13
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.13
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -64,10 +64,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.13
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.13
- name: Install Dependencies - name: Install Dependencies
run: pip3 install licenseheaders run: pip3 install licenseheaders
- name: Run licenseheaders - name: Run licenseheaders
+4 -2
View File
@@ -23,13 +23,15 @@ jobs:
name: Tests name: Tests
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.11", "3.13"] python-version: ["3.11", "3.13", "3.14"]
database: [sqlite, postgres] database: [sqlite, postgres]
exclude: exclude:
- database: sqlite - database: sqlite
python-version: "3.10" python-version: "3.10"
- database: sqlite - database: sqlite
python-version: "3.11" python-version: "3.11"
- database: sqlite
python-version: "3.12"
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
@@ -81,4 +83,4 @@ jobs:
file: src/coverage.xml file: src/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false fail_ci_if_error: false
if: matrix.database == 'postgres' && matrix.python-version == '3.11' if: matrix.database == 'postgres' && matrix.python-version == '3.13'
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11-bookworm FROM python:3.13-trixie
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
+10 -9
View File
@@ -3,7 +3,7 @@ name = "pretix"
dynamic = ["version"] dynamic = ["version"]
description = "Reinventing presales, one ticket at a time" description = "Reinventing presales, one ticket at a time"
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.10" requires-python = ">=3.11"
license = {file = "LICENSE"} license = {file = "LICENSE"}
keywords = ["tickets", "web", "shop", "ecommerce"] keywords = ["tickets", "web", "shop", "ecommerce"]
authors = [ authors = [
@@ -19,10 +19,11 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Environment :: Web Environment", "Environment :: Web Environment",
"License :: OSI Approved :: GNU Affero General Public License v3", "License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Framework :: Django :: 4.2", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: Django :: 5.2",
] ]
dependencies = [ dependencies = [
@@ -36,7 +37,7 @@ dependencies = [
"css-inline==0.20.*", "css-inline==0.20.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"dnspython==2.*", "dnspython==2.*",
"Django[argon2]==4.2.*,>=4.2.26", "Django[argon2]==5.2.*",
"django-bootstrap3==26.1", "django-bootstrap3==26.1",
"django-compressor==4.6.0", "django-compressor==4.6.0",
"django-countries==8.2.*", "django-countries==8.2.*",
@@ -59,7 +60,7 @@ dependencies = [
"dnspython==2.8.*", "dnspython==2.8.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==5.*", "geoip2==5.*",
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+ "importlib_metadata==9.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek", "isoweek",
"jsonschema", "jsonschema",
"kombu==5.6.*", "kombu==5.6.*",
@@ -75,7 +76,7 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.12.*", "PyJWT==2.12.*",
"phonenumberslite==9.0.*", "phonenumberslite==9.0.*",
"Pillow==12.1.*", "Pillow==12.2.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==7.34.*", "protobuf==7.34.*",
"psycopg2-binary", "psycopg2-binary",
@@ -89,10 +90,10 @@ dependencies = [
"pytz-deprecation-shim==0.1.*", "pytz-deprecation-shim==0.1.*",
"pyuca", "pyuca",
"qrcode==8.2", "qrcode==8.2",
"redis==7.1.*", "redis==7.4.*",
"reportlab==4.4.*", "reportlab==4.4.*",
"requests==2.32.*", "requests==2.32.*",
"sentry-sdk==2.54.*", "sentry-sdk==2.57.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2026.3.0.dev0" __version__ = "2026.4.0.dev0"
+8 -5
View File
@@ -1412,6 +1412,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
qa = QuotaAvailability() qa = QuotaAvailability()
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0]) qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
qa.compute() qa.compute()
v_avail = {}
# These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental # These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
# use further down # use further down
@@ -1441,11 +1442,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
voucher_usage[v] += 1 voucher_usage[v] += 1
if voucher_usage[v] > 0: if voucher_usage[v] > 0:
redeemed_in_carts = CartPosition.objects.filter( if v not in v_avail:
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt) v.refresh_from_db(fields=['redeemed'])
).exclude(pk__in=[cp.pk for cp in delete_cps]) redeemed_in_carts = CartPosition.objects.filter(
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count() Q(voucher=v) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
if v_avail < voucher_usage[v]: ).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'] = [ errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.' 'The voucher has already been used the maximum number of times.'
] ]
+1 -1
View File
@@ -1122,7 +1122,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
permission = 'event.orders:read' permission = 'event.orders:read'
def get_queryset(self): def get_queryset(self):
qs = Checkin.all.filter().select_related( qs = Checkin.all.filter(list__event=self.request.event).select_related(
"position", "position",
"device", "device",
) )
+13 -3
View File
@@ -47,6 +47,7 @@ from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from pretix.base.models import Event from pretix.base.models import Event
from pretix.base.models.auth import PermissionHolder
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe, SafeWorkbook, remove_invalid_excel_chars as excel_safe,
) )
@@ -59,11 +60,20 @@ class BaseExporter:
This is the base class for all data exporters This is the base class for all data exporters
""" """
def __init__(self, event, organizer, progress_callback=lambda v: None): def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None):
"""
:param event: Event context, can also be a queryset of events for multi-event exports
:param organizer: Organizer context
:param user: The user who triggered the export (or None).
:param token: The API token that triggered the export (or None).
:param device: The device that triggered the export (or None)
:param progress_callback: Callback function with progress
"""
self.event = event self.event = event
self.organizer = organizer self.organizer = organizer
self.progress_callback = progress_callback self.progress_callback = progress_callback
self.is_multievent = isinstance(event, QuerySet) self.is_multievent = isinstance(event, QuerySet)
self.permission_holder = permission_holder
if isinstance(event, QuerySet): if isinstance(event, QuerySet):
self.events = event self.events = event
self.event = None self.event = None
@@ -180,7 +190,7 @@ class BaseExporter:
return True return True
@classmethod @classmethod
def get_required_event_permission(cls) -> str: def get_required_event_permission(cls) -> Optional[str]:
""" """
The permission level required to use this exporter for events. For multi-event-exports, this will be used The permission level required to use this exporter for events. For multi-event-exports, this will be used
to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used. to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used.
@@ -195,7 +205,7 @@ class OrganizerLevelExportMixin:
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin") raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
@classmethod @classmethod
def get_required_organizer_permission(cls) -> str: def get_required_organizer_permission(cls) -> Optional[str]:
""" """
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to
allow everyone with any access to the organizer. allow everyone with any access to the organizer.
+1 -2
View File
@@ -196,8 +196,7 @@ class RegistrationForm(forms.Form):
def clean_password(self): def clean_password(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
user = User(email=self.cleaned_data.get('email')) user = User(email=self.cleaned_data.get('email'))
if validate_password(password1, user=user) is not None: validate_password(password1, user=user)
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1 return password1
def clean_email(self): def clean_email(self):
+2 -2
View File
@@ -45,7 +45,6 @@ import pycountry
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.gis.geoip2 import GeoIP2
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import ( from django.core.validators import (
@@ -102,6 +101,7 @@ from pretix.helpers.countries import (
from pretix.helpers.escapejson import escapejson_attr from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.http import get_client_ip from pretix.helpers.http import get_client_ip
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.helpers.security import get_geoip
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -393,7 +393,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def guess_country_from_request(request, event): def guess_country_from_request(request, event):
if settings.HAS_GEOIP: if settings.HAS_GEOIP:
g = GeoIP2() g = get_geoip()
try: try:
res = g.country(get_client_ip(request)) res = g.country(get_client_ip(request))
if res['country_code'] and len(res['country_code']) == 2: if res['country_code'] and len(res['country_code']) == 2:
@@ -36,8 +36,9 @@ from django.core.management.commands.makemigrations import Command as Parent
from ._migrations import monkeypatch_migrations from ._migrations import monkeypatch_migrations
monkeypatch_migrations()
class Command(Parent): class Command(Parent):
pass
def handle(self, *args, **kwargs):
monkeypatch_migrations()
return super().handle(*args, **kwargs)
@@ -64,7 +64,7 @@ class Command(BaseCommand):
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS: if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
return return
for receiver in periodic_task._live_receivers(self): for receiver in periodic_task._live_receivers(self)[0]:
name = f'{receiver.__module__}.{receiver.__name__}' name = f'{receiver.__module__}.{receiver.__name__}'
if options['list_tasks']: if options['list_tasks']:
print(name) print(name)
@@ -41,16 +41,20 @@ class Migration(migrations.Migration):
name='datetime', name='datetime',
field=models.DateTimeField(), field=models.DateTimeField(),
), ),
migrations.AlterIndexTogether( migrations.AddIndex(
name='logentry', 'logentry',
index_together={('datetime', 'id')}, models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b1fe5a_idx"),
), ),
migrations.AlterIndexTogether( migrations.AddIndex(
name='order', 'order',
index_together={('datetime', 'id'), ('last_modified', 'id')}, models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"),
), ),
migrations.AlterIndexTogether( migrations.AddIndex(
name='transaction', 'order',
index_together={('datetime', 'id')}, models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"),
),
migrations.AddIndex(
'transaction',
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b20405_idx"),
), ),
] ]
@@ -61,7 +61,10 @@ class Migration(migrations.Migration):
options={ options={
'ordering': ('identifier', 'type', 'organizer'), 'ordering': ('identifier', 'type', 'organizer'),
'unique_together': {('identifier', 'type', 'organizer')}, 'unique_together': {('identifier', 'type', 'organizer')},
'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')}, 'indexes': [
models.Index(fields=('identifier', 'type', 'organizer'), name='reusable_medium_organizer_index'),
models.Index(fields=('updated', 'id'), name="pretixbase__updated_093277_idx")
],
}, },
bases=(models.Model, pretix.base.models.base.LoggingMixin), bases=(models.Model, pretix.base.models.base.LoggingMixin),
), ),
-25
View File
@@ -9,31 +9,6 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RenameIndex(
model_name="logentry",
new_name="pretixbase__datetim_b1fe5a_idx",
old_fields=("datetime", "id"),
),
migrations.RenameIndex(
model_name="order",
new_name="pretixbase__datetim_66aff0_idx",
old_fields=("datetime", "id"),
),
migrations.RenameIndex(
model_name="order",
new_name="pretixbase__last_mo_4ebf8b_idx",
old_fields=("last_modified", "id"),
),
migrations.RenameIndex(
model_name="reusablemedium",
new_name="pretixbase__updated_093277_idx",
old_fields=("updated", "id"),
),
migrations.RenameIndex(
model_name="transaction",
new_name="pretixbase__datetim_b20405_idx",
old_fields=("datetime", "id"),
),
migrations.AlterField( migrations.AlterField(
model_name="attendeeprofile", model_name="attendeeprofile",
name="id", name="id",
@@ -1,6 +1,6 @@
# Generated by Django 4.2.10 on 2024-04-02 15:16 # Generated by Django 4.2.10 on 2024-04-02 15:16
from django.db import migrations from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -10,8 +10,8 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.AlterIndexTogether( migrations.RemoveIndex(
name="reusablemedium", "reusablemedium",
index_together=set(), 'reusable_medium_organizer_index',
), ),
] ]
+4
View File
@@ -70,6 +70,10 @@ def parse_csv(file, length=None, mode="strict", charset=None):
except ImportError: except ImportError:
charset = file.charset charset = file.charset
data = data.decode(charset or "utf-8", mode) data = data.decode(charset or "utf-8", mode)
# remove stray linebreaks from the end of the file
data = data.rstrip("\n")
# If the file was modified on a Mac, it only contains \r as line breaks # If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data: if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n') data = data.replace('\r', '\n')
+9 -3
View File
@@ -29,7 +29,9 @@ import inspect
import logging import logging
import os import os
import threading import threading
from pathlib import Path
import django
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
@@ -74,10 +76,14 @@ def _transactions_mark_order_dirty(order_id, using=None):
if "PYTEST_CURRENT_TEST" in os.environ: if "PYTEST_CURRENT_TEST" in os.environ:
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code # We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
# or not. # or not.
for frame in inspect.stack(): for frame in inspect.stack()[1:]:
if 'pretix/base/models/orders' in frame.filename: if (
'pretix/base/models/orders' in frame.filename
or Path(frame.filename).is_relative_to(Path(django.__file__).parent)
):
# Ignore model- and django-internal code
continue continue
elif 'test_' in frame.filename or 'conftest.py in frame.filename': elif 'test_' in frame.filename or 'conftest.py' in frame.filename:
return return
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename: elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
# This went through non-test code, let's consider it non-test # This went through non-test code, let's consider it non-test
+21
View File
@@ -38,6 +38,7 @@ import operator
import secrets import secrets
from datetime import timedelta from datetime import timedelta
from functools import reduce from functools import reduce
from typing import Protocol
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import ( from django.contrib.auth.models import (
@@ -67,6 +68,14 @@ class EmailAddressTakenError(IntegrityError):
pass pass
class PermissionHolder(Protocol):
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
...
def has_organizer_permission(self, organizer, perm_name=None, request=None):
...
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
""" """
This is the user manager for our custom user model. See the User This is the user manager for our custom user model. See the User
@@ -696,6 +705,18 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return self.teams.exists() return self.teams.exists()
class UserWithStaffSession:
# Wrapper around a User object with a staff session, implementing the PermissionHolder Protocol
def __init__(self, user):
self.user = user
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
return True
def has_organizer_permission(self, organizer, perm_name=None, request=None):
return True
class UserKnownLoginSource(models.Model): class UserKnownLoginSource(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources") user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
agent_type = models.CharField(max_length=255, null=True, blank=True) agent_type = models.CharField(max_length=255, null=True, blank=True)
+2 -1
View File
@@ -229,7 +229,7 @@ class Device(LoggedModel):
""" """
return self._organizer_permission_set() if self.organizer == organizer else set() return self._organizer_permission_set() if self.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
""" """
Checks if this token is part of a team that grants access of type ``perm_name`` Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``. to the event ``event``.
@@ -238,6 +238,7 @@ class Device(LoggedModel):
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read`` :param perm_name: The permission, e.g. ``event.orders:read``
:param request: This parameter is ignored and only defined for compatibility reasons. :param request: This parameter is ignored and only defined for compatibility reasons.
:param session_key: This parameter is ignored and only defined for compatibility reasons.
:return: bool :return: bool
""" """
has_event_access = (self.all_events and organizer == self.organizer) or ( has_event_access = (self.all_events and organizer == self.organizer) or (
+1 -1
View File
@@ -88,7 +88,7 @@ class LogEntry(models.Model):
class Meta: class Meta:
ordering = ('-datetime', '-id') ordering = ('-datetime', '-id')
indexes = [models.Index(fields=["datetime", "id"])] indexes = [models.Index(fields=["datetime", "id"], name="pretixbase__datetim_b1fe5a_idx")]
def display(self): def display(self):
from pretix.base.logentrytype_registry import log_entry_types from pretix.base.logentrytype_registry import log_entry_types
+1 -1
View File
@@ -122,7 +122,7 @@ class ReusableMedium(LoggedModel):
class Meta: class Meta:
unique_together = (("identifier", "type", "organizer"),) unique_together = (("identifier", "type", "organizer"),)
indexes = [ indexes = [
models.Index(fields=("updated", "id")), models.Index(fields=("updated", "id"), name="pretixbase__updated_093277_idx"),
] ]
ordering = "identifier", "type", "organizer" ordering = "identifier", "type", "organizer"
+5 -5
View File
@@ -336,8 +336,8 @@ class Order(LockModel, LoggedModel):
verbose_name_plural = _("Orders") verbose_name_plural = _("Orders")
ordering = ("-datetime", "-pk") ordering = ("-datetime", "-pk")
indexes = [ indexes = [
models.Index(fields=["datetime", "id"]), models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"),
models.Index(fields=["last_modified", "id"]), models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"),
] ]
constraints = [ constraints = [
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"), models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
@@ -590,7 +590,7 @@ class Order(LockModel, LoggedModel):
not kwargs.get('force_save_with_deferred_fields', None) and not kwargs.get('force_save_with_deferred_fields', None) and
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields)) (not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
): ):
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed " _fail("It is unsafe to call save() on an Order with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.") "this.")
@@ -2841,7 +2841,7 @@ class OrderPosition(AbstractPosition):
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk: if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None)) _transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
elif not kwargs.get('force_save_with_deferred_fields', None): elif not kwargs.get('force_save_with_deferred_fields', None):
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed " _fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.") "this.")
@@ -3080,7 +3080,7 @@ class Transaction(models.Model):
class Meta: class Meta:
ordering = 'datetime', 'pk' ordering = 'datetime', 'pk'
indexes = [ indexes = [
models.Index(fields=['datetime', 'id']) models.Index(fields=['datetime', 'id'], name="pretixbase__datetim_b20405_idx")
] ]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
+8 -1
View File
@@ -319,6 +319,9 @@ class TeamQuerySet(models.QuerySet):
def event_permission_q(cls, perm_name): def event_permission_q(cls, perm_name):
from ..permissions import assert_valid_event_permission from ..permissions import assert_valid_event_permission
if perm_name is None:
return Q()
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy
return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]]) return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]])
assert_valid_event_permission(perm_name, allow_legacy=False) assert_valid_event_permission(perm_name, allow_legacy=False)
@@ -331,6 +334,9 @@ class TeamQuerySet(models.QuerySet):
def organizer_permission_q(cls, perm_name): def organizer_permission_q(cls, perm_name):
from ..permissions import assert_valid_organizer_permission from ..permissions import assert_valid_organizer_permission
if perm_name is None:
return Q()
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy
return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]]) return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]])
assert_valid_organizer_permission(perm_name, allow_legacy=False) assert_valid_organizer_permission(perm_name, allow_legacy=False)
@@ -550,7 +556,7 @@ class TeamAPIToken(models.Model):
""" """
return self.team.organizer_permission_set() if self.team.organizer == organizer else set() return self.team.organizer_permission_set() if self.team.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
""" """
Checks if this token is part of a team that grants access of type ``perm_name`` Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``. to the event ``event``.
@@ -559,6 +565,7 @@ class TeamAPIToken(models.Model):
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read`` :param perm_name: The permission, e.g. ``event.orders:read``
:param request: This parameter is ignored and only defined for compatibility reasons. :param request: This parameter is ignored and only defined for compatibility reasons.
:param session_key: This parameter is ignored and only defined for compatibility reasons.
:return: bool :return: bool
""" """
has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
+16 -5
View File
@@ -54,7 +54,7 @@ from bidi import get_display
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Max, Min from django.db.models import Exists, Max, Min, OuterRef
from django.db.models.fields.files import FieldFile from django.db.models.fields.files import FieldFile
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
@@ -76,7 +76,7 @@ from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph from reportlab.platypus import Paragraph
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Event, Order, OrderPosition, Question from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
@@ -379,6 +379,13 @@ DEFAULT_VARIABLES = OrderedDict((
str(p) for p in generate_compressed_addon_list(op, order, ev) str(p) for p in generate_compressed_addon_list(op, order, ev)
]) ])
}), }),
("checked_in_addons", {
"label": _("List of Checked-In Add-Ons"),
"editor_sample": _("Add-on 1\n2x Add-on 2"),
"evaluate": lambda op, order, ev: "\n".join([
str(p) for p in generate_compressed_addon_list(op, order, ev, only_checked_in=True)
])
}),
("organizer", { ("organizer", {
"label": _("Organizer name"), "label": _("Organizer name"),
"editor_sample": _("Event organizer company"), "editor_sample": _("Event organizer company"),
@@ -750,12 +757,16 @@ def get_program_times(op: OrderPosition, ev: Event):
]) ])
def generate_compressed_addon_list(op, order, event): def generate_compressed_addon_list(op, order, event, only_checked_in=False):
itemcount = defaultdict(int) itemcount = defaultdict(int)
addons = [p for p in ( addon_qs = (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {}) op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation') else op.addons.select_related('item', 'variation')
) if not p.canceled] )
if only_checked_in:
addon_qs = addon_qs.filter(Exists(Checkin.objects.filter(position=OuterRef('pk'))), canceled=False)
addons = [p for p in addon_qs if not p.canceled]
for pos in addons: for pos in addons:
itemcount[pos.item, pos.variation] += 1 itemcount[pos.item, pos.variation] += 1
+19 -3
View File
@@ -40,6 +40,7 @@ from pretix.base.models import (
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken, CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
User, cachedfile_name, User, cachedfile_name,
) )
from pretix.base.models.auth import UserWithStaffSession
from pretix.base.models.exports import ScheduledOrganizerExport from pretix.base.models.exports import ScheduledOrganizerExport
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
from pretix.base.services.tasks import ( from pretix.base.services.tasks import (
@@ -211,7 +212,12 @@ def init_event_exporters(event, user=None, token=None, device=None, request=None
if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session: if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session:
continue continue
exporter: BaseExporter = response(event=event, organizer=event.organizer, **kwargs) exporter: BaseExporter = response(
event=event,
organizer=event.organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs
)
if not exporter.available_for_user(user if user and user.is_authenticated else None): if not exporter.available_for_user(user if user and user.is_authenticated else None):
continue continue
@@ -243,7 +249,12 @@ def init_organizer_exporters(
continue continue
if issubclass(response, OrganizerLevelExportMixin): if issubclass(response, OrganizerLevelExportMixin):
exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, **kwargs) exporter: BaseExporter = response(
event=Event.objects.none(),
organizer=organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs,
)
try: try:
if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session: if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session:
@@ -295,7 +306,12 @@ def init_organizer_exporters(
if not _has_permission_on_any_team_cache[permission_name] and not staff_session: if not _has_permission_on_any_team_cache[permission_name] and not staff_session:
continue continue
exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, **kwargs) exporter: BaseExporter = response(
event=_event_list_cache[permission_name],
organizer=organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs,
)
if not exporter.available_for_user(user if user and user.is_authenticated else None): if not exporter.available_for_user(user if user and user.is_authenticated else None):
continue continue
+1 -1
View File
@@ -411,7 +411,7 @@ def mail_send_task(self, **kwargs) -> bool:
try: try:
outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail) outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail)
except OutgoingMail.DoesNotExist: except OutgoingMail.DoesNotExist:
logger.info(f"Ignoring job for non existing email {outgoing_mail.guid}") logger.info(f"Ignoring job for non existing email {outgoing_mail}")
return False return False
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT: if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}") logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}")
+70 -50
View File
@@ -67,9 +67,9 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership, CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
Voucher, SeatCategoryMapping, User, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import ( from pretix.base.models.orders import (
@@ -727,8 +727,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
_check_date(event, time_machine_now_dt) _check_date(event, time_machine_now_dt)
products_seen = Counter() products_seen = Counter()
q_avail = Counter()
v_avail = Counter()
v_usages = Counter() v_usages = Counter()
v_budget = {} v_budget = {}
deleted_positions = set() deleted_positions = set()
@@ -793,6 +791,9 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
shared_lock_objects=[event] shared_lock_objects=[event]
) )
q_avail = Counter()
v_avail = Counter()
# Check maximum order size # Check maximum order size
limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_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: if sum(1 for cp in sorted_positions if not cp.addon_to) > limit:
@@ -1618,7 +1619,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result')) 'valid_from', 'valid_until', 'is_bundled', 'result', 'count'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1632,16 +1633,24 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult: class AddPositionResult:
_position: Optional[OrderPosition] _positions: Optional[List[OrderPosition]]
def __init__(self): def __init__(self):
self._position = None self._positions = None
@property @property
def position(self) -> OrderPosition: def position(self) -> OrderPosition:
if self._position is None: if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._position if len(self._positions) != 1:
raise RuntimeError("More than one position created.")
return self._positions[0]
@property
def positions(self) -> List[OrderPosition]:
if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._positions
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False): def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order self.order = order
@@ -1848,8 +1857,12 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult': valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
if isinstance(seat, str): if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat: if not seat:
seat = None seat = None
else: else:
@@ -1903,14 +1916,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross self._totaldiff_guesstimate += price.gross * count
self._quotadiff.update(new_quotas) self._quotadiff.update({q: count for q in new_quotas})
if seat: if seat:
self._seatdiff.update([seat]) self._seatdiff.update([seat])
result = self.AddPositionResult() result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result)) valid_from, valid_until, is_bundled, result, count))
return result return result
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
@@ -2530,29 +2543,35 @@ class OrderChangeManager:
secret_dirty.remove(position) secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret']) position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create( new_pos = []
item=op.item, variation=op.variation, addon_to=op.addon_to, new_logs = []
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, for i in range(op.count):
tax_value=op.price.tax, tax_rule=op.item.tax_rule, pos = OrderPosition.objects.create(
positionid=nextposid, subevent=op.subevent, seat=op.seat, item=op.item, variation=op.variation, addon_to=op.addon_to,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
is_bundled=op.is_bundled, tax_value=op.price.tax, tax_rule=op.item.tax_rule,
) positionid=nextposid, subevent=op.subevent, seat=op.seat,
nextposid += 1 used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ is_bundled=op.is_bundled,
'position': pos.pk, )
'item': op.item.pk, nextposid += 1
'variation': op.variation.pk if op.variation else None, new_pos.append(pos)
'addon_to': op.addon_to.pk if op.addon_to else None, new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'price': op.price.gross, 'position': pos.pk,
'positionid': pos.positionid, 'item': op.item.pk,
'membership': pos.used_membership_id, 'variation': op.variation.pk if op.variation else None,
'subevent': op.subevent.pk if op.subevent else None, 'addon_to': op.addon_to.pk if op.addon_to else None,
'seat': op.seat.pk if op.seat else None, 'price': op.price.gross,
'valid_from': op.valid_from.isoformat() if op.valid_from else None, 'positionid': pos.positionid,
'valid_until': op.valid_until.isoformat() if op.valid_until else None, 'membership': pos.used_membership_id,
}) 'subevent': op.subevent.pk if op.subevent else None,
op.result._position = pos 'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
}, save=False))
op.result._positions = new_pos
LogEntry.bulk_create_and_postprocess(new_logs)
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position) split_positions.append(position)
@@ -2877,7 +2896,7 @@ class OrderChangeManager:
return total return total
def _check_order_size(self): def _check_order_size(self):
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
raise OrderError( raise OrderError(
self.error_messages['max_order_size'] % { self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE, 'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2938,7 +2957,7 @@ class OrderChangeManager:
]) + len([ ]) + len([
o for o in self._operations if isinstance(o, self.SplitOperation) o for o in self._operations if isinstance(o, self.SplitOperation)
]) ])
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)]) adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)])
if current > 0 and current - cancels + adds < 1: if current > 0 and current - cancels + adds < 1:
raise OrderError(self.error_messages['complete_cancel']) raise OrderError(self.error_messages['complete_cancel'])
@@ -2985,17 +3004,18 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position]) fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
cp = CartPosition( for i in range(op.count):
event=self.event, cp = CartPosition(
item=op.item, event=self.event,
variation=op.variation, item=op.item,
used_membership=op.membership, variation=op.variation,
subevent=op.subevent, used_membership=op.membership,
seat=op.seat, subevent=op.subevent,
) seat=op.seat,
cp.override_valid_from = op.valid_from )
cp.override_valid_until = op.valid_until cp.override_valid_from = op.valid_from
fake_cart.append(cp) cp.override_valid_until = op.valid_until
fake_cart.append(cp)
try: try:
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode) validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
except ValidationError as e: except ValidationError as e:
+9 -1
View File
@@ -100,7 +100,7 @@ def primary_font_kwargs():
choices = [('Open Sans', 'Open Sans')] choices = [('Open Sans', 'Open Sans')]
choices += sorted([ choices += sorted([
(a, {"title": a, "data": v}) for a, v in get_fonts(pdf_support_required=False).items() (a, FontSelect.FontOption(title=a, data=v)) for a, v in get_fonts(pdf_support_required=False).items()
], key=lambda a: a[0]) ], key=lambda a: a[0])
return { return {
'choices': choices, 'choices': choices,
@@ -4148,6 +4148,14 @@ def validate_event_settings(event, settings_dict):
) )
]} ]}
) )
if (
settings_dict.get('invoice_address_from_vat_id') and
settings_dict.get('invoice_address_from_country') and
settings_dict.get('invoice_address_from_country') not in VAT_ID_COUNTRIES
):
raise ValidationError({
'invoice_address_from_vat_id': _('VAT-ID is not supported for "{}".').format(settings_dict.get('invoice_address_from_country'))
})
payment_term_last = settings_dict.get('payment_term_last') payment_term_last = settings_dict.get('payment_term_last')
if payment_term_last and event.presale_end: if payment_term_last and event.presale_end:
+60 -19
View File
@@ -32,6 +32,7 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import logging
import warnings import warnings
from typing import Any, Callable, Generic, List, Tuple, TypeVar from typing import Any, Callable, Generic, List, Tuple, TypeVar
@@ -48,6 +49,8 @@ from .plugins import (
PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_ORGANIZER,
) )
logger = logging.getLogger(__name__)
app_cache = {} app_cache = {}
T = TypeVar('T') T = TypeVar('T')
@@ -60,23 +63,25 @@ def _populate_app_cache():
def get_defining_app(o): def get_defining_app(o):
# If sentry packed this in a wrapper, unpack that # If sentry packed this in a wrapper, unpack that
if "sentry" in o.__module__: module = getattr(o, "__module__", None)
if module and "sentry" in module:
o = o.__wrapped__ o = o.__wrapped__
if hasattr(o, "__mocked_app"): if hasattr(o, "__mocked_app"):
return o.__mocked_app return o.__mocked_app
# Find the Django application this belongs to # Find the Django application this belongs to
searchpath = o.__module__ searchpath = module or getattr(o.__class__, "__module__", None) or ""
# Core modules are always active # Core modules are always active
if any(searchpath.startswith(cm) for cm in settings.CORE_MODULES): if searchpath and any(searchpath.startswith(cm) for cm in settings.CORE_MODULES):
return 'CORE' return 'CORE'
if not app_cache: if not app_cache:
_populate_app_cache() _populate_app_cache()
while True: app = None
while searchpath:
app = app_cache.get(searchpath) app = app_cache.get(searchpath)
if "." not in searchpath or app: if "." not in searchpath or app:
break break
@@ -157,7 +162,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
if not app_cache: if not app_cache:
_populate_app_cache() _populate_app_cache()
for receiver in self._sorted_receivers(sender): for receiver in self._live_receivers(sender)[0]:
if self._is_receiver_active(sender, receiver): if self._is_receiver_active(sender, receiver):
response = receiver(signal=self, sender=sender, **named) response = receiver(signal=self, sender=sender, **named)
responses.append((receiver, response)) responses.append((receiver, response))
@@ -179,7 +184,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
if not app_cache: if not app_cache:
_populate_app_cache() _populate_app_cache()
for receiver in self._sorted_receivers(sender): for receiver in self._live_receivers(sender)[0]:
if self._is_receiver_active(sender, receiver): if self._is_receiver_active(sender, receiver):
named[chain_kwarg_name] = response named[chain_kwarg_name] = response
response = receiver(signal=self, sender=sender, **named) response = receiver(signal=self, sender=sender, **named)
@@ -204,7 +209,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
if not app_cache: if not app_cache:
_populate_app_cache() _populate_app_cache()
for receiver in self._sorted_receivers(sender): for receiver in self._live_receivers(sender)[0]:
if self._is_receiver_active(sender, receiver): if self._is_receiver_active(sender, receiver):
try: try:
response = receiver(signal=self, sender=sender, **named) response = receiver(signal=self, sender=sender, **named)
@@ -214,17 +219,35 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
responses.append((receiver, response)) responses.append((receiver, response))
return responses return responses
def _sorted_receivers(self, sender): def asend(self, sender: T, **named):
orig_list = self._live_receivers(sender) raise NotImplementedError() # NOQA
def asend_robust(self, sender: T, **named):
raise NotImplementedError() # NOQA
def _live_receivers(self, sender):
orig_list, orig_async_list = super()._live_receivers(sender)
if orig_async_list:
logger.error('Async receivers are not supported.')
raise NotImplementedError
def _getattr_fallback_to_class(obj, key):
return getattr(obj, key, getattr(obj.__class__, key))
def _is_core_module(receiver):
m = _getattr_fallback_to_class(receiver, "__module__")
return any(m.startswith(c) for c in settings.CORE_MODULES)
sorted_list = sorted( sorted_list = sorted(
orig_list, orig_list,
key=lambda receiver: ( key=lambda receiver: (
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1, 0 if _is_core_module(receiver) else 1,
receiver.__module__, _getattr_fallback_to_class(receiver, "__module__"),
receiver.__name__, _getattr_fallback_to_class(receiver, "__name__"),
) )
) )
return sorted_list return sorted_list, []
class EventPluginSignal(PluginSignal[Event]): class EventPluginSignal(PluginSignal[Event]):
@@ -300,23 +323,41 @@ class GlobalSignal(django.dispatch.Signal):
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS: if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
return response return response
for receiver in self._live_receivers(sender): for receiver in self._live_receivers(sender)[0]:
named[chain_kwarg_name] = response named[chain_kwarg_name] = response
response = receiver(signal=self, sender=sender, **named) response = receiver(signal=self, sender=sender, **named)
return response return response
def asend(self, sender: T, **named):
raise NotImplementedError() # NOQA
def asend_robust(self, sender: T, **named):
raise NotImplementedError() # NOQA
def _live_receivers(self, sender): def _live_receivers(self, sender):
# Ensure consistent sorting of receivers # Ensure consistent sorting of receivers
orig_list = super()._live_receivers(sender) orig_list, orig_async_list = super()._live_receivers(sender)
if orig_async_list:
logger.error('Async receivers are not supported.')
raise NotImplementedError
def _getattr_fallback_to_class(obj, key):
return getattr(obj, key, getattr(obj.__class__, key))
def _is_core_module(receiver):
m = _getattr_fallback_to_class(receiver, "__module__")
return any(m.startswith(c) for c in settings.CORE_MODULES)
sorted_list = sorted( sorted_list = sorted(
orig_list, orig_list,
key=lambda receiver: ( key=lambda receiver: (
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1, 0 if _is_core_module(receiver) else 1,
receiver.__module__, _getattr_fallback_to_class(receiver, "__module__"),
receiver.__name__, _getattr_fallback_to_class(receiver, "__name__"),
) )
) )
return sorted_list return sorted_list, []
class DeprecatedSignal(GlobalSignal): class DeprecatedSignal(GlobalSignal):
+6
View File
@@ -34,6 +34,7 @@
import datetime import datetime
import os import os
from dataclasses import dataclass
from django import forms from django import forms
from django.conf import settings from django.conf import settings
@@ -420,6 +421,11 @@ class SplitDateTimeField(forms.SplitDateTimeField):
class FontSelect(forms.RadioSelect): class FontSelect(forms.RadioSelect):
option_template_name = 'pretixcontrol/font_option.html' option_template_name = 'pretixcontrol/font_option.html'
@dataclass
class FontOption:
title: str
data: str
class ItemMultipleChoiceField(SafeModelMultipleChoiceField): class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
+11 -4
View File
@@ -63,7 +63,7 @@ from pretix.base.forms import (
from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.models.organizer import TeamQuerySet from pretix.base.models.organizer import TeamQuerySet
from pretix.base.models.tax import TAX_CODE_LISTS from pretix.base.models.tax import TAX_CODE_LISTS, VAT_ID_COUNTRIES
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import ( from pretix.base.settings import (
@@ -73,8 +73,8 @@ from pretix.base.settings import (
) )
from pretix.base.validators import multimail_validate from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple, SlugWidget, FontSelect, MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple,
SplitDateTimeField, SplitDateTimePickerWidget, SlugWidget, SplitDateTimeField, SplitDateTimePickerWidget,
) )
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
@@ -531,6 +531,13 @@ class EventUpdateForm(I18nModelForm):
class EventSettingsValidationMixin: class EventSettingsValidationMixin:
def clean_invoice_address_from_vat_id(self):
value = self.cleaned_data.get('invoice_address_from_vat_id')
country = self.cleaned_data.get('invoice_address_from_country')
if value and country and country not in VAT_ID_COUNTRIES:
return None
return value
def clean(self): def clean(self):
data = super().clean() data = super().clean()
settings_dict = self.obj.settings.freeze() settings_dict = self.obj.settings.freeze()
@@ -722,7 +729,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
del self.fields['event_list_filters'] del self.fields['event_list_filters']
del self.fields['event_calendar_future_only'] del self.fields['event_calendar_future_only']
self.fields['primary_font'].choices = [('Open Sans', 'Open Sans')] + sorted([ self.fields['primary_font'].choices = [('Open Sans', 'Open Sans')] + sorted([
(a, {"title": a, "data": v}) for a, v in get_fonts(self.event, pdf_support_required=False).items() (a, FontSelect.FontOption(title=a, data=v)) for a, v in get_fonts(self.event, pdf_support_required=False).items()
], key=lambda a: a[0]) ], key=lambda a: a[0])
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields # create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
+8
View File
@@ -331,6 +331,10 @@ class OtherOperationsForm(forms.Form):
class OrderPositionAddForm(forms.Form): class OrderPositionAddForm(forms.Form):
count = forms.IntegerField(
label=_('Number of products to add'),
initial=1,
)
itemvar = forms.ChoiceField( itemvar = forms.ChoiceField(
label=_('Product') label=_('Product')
) )
@@ -432,6 +436,10 @@ class OrderPositionAddForm(forms.Form):
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0] d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
else: else:
d['used_membership'] = None d['used_membership'] = None
if d.get("count", 1) > 1 and d.get("seat"):
raise ValidationError({
"seat": _("You can not choose a seat when adding multiple products at once.")
})
return d return d
+1 -1
View File
@@ -363,7 +363,7 @@ def get_global_navigation(request):
'icon': 'dashboard', 'icon': 'dashboard',
}, },
] ]
if request.user.is_in_any_teams: if request.user.is_in_any_teams or request.user.is_staff:
nav += [ nav += [
{ {
'label': _('Events'), 'label': _('Events'),
@@ -329,6 +329,7 @@
{{ add_form.custom_error }} {{ add_form.custom_error }}
</div> </div>
{% endif %} {% endif %}
{% bootstrap_field add_form.count layout="control" %}
{% bootstrap_field add_form.itemvar layout="control" %} {% bootstrap_field add_form.itemvar layout="control" %}
{% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %} {% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %}
{% if add_form.addon_to %} {% if add_form.addon_to %}
@@ -364,6 +365,7 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="form-horizontal"> <div class="form-horizontal">
{% bootstrap_field add_position_formset.empty_form.count layout="control" %}
{% bootstrap_field add_position_formset.empty_form.itemvar layout="control" %} {% bootstrap_field add_position_formset.empty_form.itemvar layout="control" %}
{% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency layout="control" %} {% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency layout="control" %}
{% if add_position_formset.empty_form.addon_to %} {% if add_position_formset.empty_form.addon_to %}
@@ -6,35 +6,44 @@
<h1>{% trans "Add a two-factor authentication device" %}</h1> <h1>{% trans "Add a two-factor authentication device" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
{% bootstrap_field form.name layout='horizontal' %} {% bootstrap_field form.name layout='horizontal' %}
<div class="form-group"> <div class="form-group{% if form.devicetype.errors %} has-error{% endif %}">
<label class="col-md-3 control-label">{% trans "Device type" %}</label> <label class="col-md-3 control-label">{% trans "Device type" %}</label>
<div class="col-md-9"> <div class="col-md-9">
<div class="big-radio radio"> <div>
<label> <div class="big-radio radio">
<input type="radio" value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}> <label>
<strong>{% trans "Smartphone with the Authenticator application" %}</strong><br> <input type="radio" required value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}>
<div class="help-block"> <strong>{% trans "Smartphone with Authenticator app" %}</strong><br>
{% blocktrans trimmed %} <div class="help-block">
Use your smartphone with any Time-based One-Time-Password app like freeOTP, Google Authenticator or Proton Authenticator. {% blocktrans trimmed %}
{% endblocktrans %} Use your smartphone with any Time-based One-Time-Password app like freeOTP, Google Authenticator or Proton Authenticator.
</div> {% endblocktrans %}
</label> </div>
</div> </label>
<div class="big-radio radio"> </div>
<label> <div class="big-radio radio">
<input type="radio" value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}> <label>
<strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br> <input type="radio" required value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}>
<div class="help-block"> <strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br>
{% blocktrans trimmed %} <div class="help-block">
Use a hardware token like the Yubikey, or biometric authentication on iOS, macOS and Android. {% blocktrans trimmed %}
{% endblocktrans %} Use a hardware token like the Yubikey, or other biometric authentication like fingerprint or face recognition.
</div> {% endblocktrans %}
</label> </div>
</label>
</div>
</div> </div>
{% if form.devicetype.errors %}
<div class="help-block">
{% for error in form.devicetype.errors %}
<p>{{ error|escape }}</p>
{% endfor %}
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -69,11 +69,14 @@
{% trans "Enter the displayed code here:" %} {% trans "Enter the displayed code here:" %}
<form class="form form-inline" method="post" action=""> <form class="form form-inline" method="post" action="">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<input type="number" name="token" class="form-control" required="required"> <input type="number" name="token" class="form-control" required="required">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
{% trans "Continue" %} {% trans "Continue" %}
</button><br> </button><br>
<label>
<input type="checkbox" name="activate" checked="checked" value="on">
{% trans "Require second factor for future logins" %}
</label>
</form> </form>
</li> </li>
</ol> </ol>
@@ -12,9 +12,13 @@
</p> </p>
<form class="form form-inline" method="post" action="" id="webauthn-form"> <form class="form form-inline" method="post" action="" id="webauthn-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<input type="hidden" id="webauthn-response" name="token" class="form-control" required="required"> <input type="hidden" id="webauthn-response" name="token" class="form-control" required="required">
<p>
<label>
<input type="checkbox" name="activate" checked="checked" value="on">
{% trans "Require second factor for future logins" %}
</label>
</p>
<button class="btn btn-primary sr-only" type="submit"></button> <button class="btn btn-primary sr-only" type="submit"></button>
</form> </form>
@@ -6,7 +6,6 @@
<h1>{% trans "Delete a two-factor authentication device" %}</h1> <h1>{% trans "Delete a two-factor authentication device" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p>{% blocktrans trimmed with device=device.name %} <p>{% blocktrans trimmed with device=device.name %}
Are you sure you want to delete the authentication device "{{ device }}"? Are you sure you want to delete the authentication device "{{ device }}"?
{% endblocktrans %}</p> {% endblocktrans %}</p>
@@ -6,7 +6,6 @@
<h1>{% trans "Disable two-factor authentication" %}</h1> <h1>{% trans "Disable two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p> <p>
{% trans "Do you really want to disable two-factor authentication?" %} {% trans "Do you really want to disable two-factor authentication?" %}
</p> </p>
@@ -1,58 +1,23 @@
{% extends "pretixcontrol/base.html" %} {% extends "pretixcontrol/base.html" %}
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "Enable two-factor authentication" %}{% endblock %} {% block title %}{% trans "Enable two-factor authentication" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "Enable two-factor authentication" %}</h1> <h1>{% trans "Enable two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p> <p>
{% trans "Do you really want to enable two-factor authentication?" %} {% trans "Do you really want to enable two-factor authentication?" %}
</p> </p>
<p> <p>
{% trans "You will no longer be able to log in to pretix without one of your configured devices." %} {% trans "You will no longer be able to log in to pretix without one of your configured devices." %}
{% trans "Please make sure to print out or copy the emergency tokens and store them in a safe place." %}
</p> </p>
{% if new_emergency_tokens %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Your emergency codes" %}</h3>
</div>
<div class="panel-body">
<p>
{% blocktrans trimmed %}
If you lose access to your devices, you can use one of your emergency tokens to log in.
We recommend to store them in a safe place, e.g. printed out or in a password manager.
Every token can be used at most once.
{% endblocktrans %}
</p>
<ul>
{% for code in new_emergency_tokens %}
<li>{{ code }}</li>
{% endfor %}
</ul>
<p>
<label>
<input type="checkbox" required>
{% trans "I stored my emergency tokens in a safe place." %}
</label>
</p>
</div>
</div>
{% else %}
<p>
{% icon "info-circle" %}
{% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %}
You generated your emergency tokens on {{ generation_date_time }}.
{% endblocktrans %}
</p>
{% endif %}
<div class="form-group submit-group"> <div class="form-group submit-group">
<a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel"> <a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-danger btn-save">
{% trans "Enable" %} {% trans "Enable" %}
</button> </button>
</div> </div>
@@ -6,7 +6,6 @@
<h1>{% trans "Leave teams that require two-factor authentication" %}</h1> <h1>{% trans "Leave teams that require two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p> <p>
<strong>{% trans "Do you really want to leave the following teams?" %}</strong> <strong>{% trans "Do you really want to leave the following teams?" %}</strong>
</p> </p>
@@ -1,6 +1,5 @@
{% extends "pretixcontrol/base.html" %} {% extends "pretixcontrol/base.html" %}
{% load i18n %} {% load i18n %}
{% load icon %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block title %}{% trans "Two-factor authentication" %}{% endblock %} {% block title %}{% trans "Two-factor authentication" %}{% endblock %}
{% block content %} {% block content %}
@@ -121,7 +120,7 @@
Delete Delete
</a> </a>
{% if d.devicetype == "totp" %} {% if d.devicetype == "totp" %}
<span class="fa fa-mobile fa-lg"></span> <span class="fa fa-mobile"></span>
{% elif d.devicetype == "webauthn" %} {% elif d.devicetype == "webauthn" %}
<span class="fa fa-usb"></span> <span class="fa fa-usb"></span>
{% elif d.devicetype == "u2f" %} {% elif d.devicetype == "u2f" %}
@@ -153,30 +152,19 @@
</p> </p>
{% if static_tokens_device %} {% if static_tokens_device %}
<p> <p>
{% icon "info-circle" %}
{% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %} {% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %}
You generated your emergency tokens on {{ generation_date_time }}. You generated your emergency tokens on {{ generation_date_time }}.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
<span class="fa fa-refresh"></span>
{% trans "Generate new emergency tokens" %}
</a>
{% elif user.require_2fa %}
<p>
{% icon "warning" %}
<strong>{% trans "You don't have any emergency tokens yet." %}</strong>
</p>
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
<span class="fa fa-refresh"></span>
{% trans "Generate emergency tokens" %}
</a>
{% else %} {% else %}
<p class="help-block"> <p>
{% icon "info-circle" %} {% trans "You don't have any emergency tokens yet." %}
{% trans "Emergency tokens will be generated when you enable two-factor authentication." %}
</p> </p>
{% endif %} {% endif %}
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
<span class="fa fa-refresh"></span>
{% trans "Generate new emergency tokens" %}
</a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -6,7 +6,6 @@
<h1>{% trans "Regenerate emergency codes" %}</h1> <h1>{% trans "Regenerate emergency codes" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p> <p>
{% trans "Do you really want to regenerate your emergency codes?" %} {% trans "Do you really want to regenerate your emergency codes?" %}
</p> </p>
@@ -8,7 +8,6 @@
{% trans "Change login email address" %} {% trans "Change login email address" %}
</h1> </h1>
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
<p class="text-muted"> <p class="text-muted">
{% trans "This changes the email address used to login to your account, as well as where we send email notifications." %} {% trans "This changes the email address used to login to your account, as well as where we send email notifications." %}
@@ -9,7 +9,6 @@
</h1> </h1>
<br> <br>
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
{% bootstrap_field form.email %} {% bootstrap_field form.email %}
{% bootstrap_field form.old_pw %} {% bootstrap_field form.old_pw %}
+1 -1
View File
@@ -641,7 +641,7 @@ def user_index(request):
ctx = { ctx = {
'widgets': rearrange(widgets), 'widgets': rearrange(widgets),
'can_create_event': request.user.teams.with_organizer_permission("organizer.events:create").exists(), 'can_create_event': request.user.teams.with_organizer_permission("organizer.events:create").exists() or request.user.is_staff,
'upcoming': widgets_for_event_qs( 'upcoming': widgets_for_event_qs(
request, request,
annotated_event_query(request, lazy=True).filter( annotated_event_query(request, lazy=True).filter(
+7 -6
View File
@@ -2059,12 +2059,13 @@ class OrderChange(OrderView):
else: else:
variation = None variation = None
try: try:
ocm.add_position(item, variation, for i in range(f.cleaned_data.get("count", 1)):
f.cleaned_data['price'], ocm.add_position(item, variation,
f.cleaned_data.get('addon_to'), f.cleaned_data['price'],
f.cleaned_data.get('subevent'), f.cleaned_data.get('addon_to'),
f.cleaned_data.get('seat'), f.cleaned_data.get('subevent'),
f.cleaned_data.get('used_membership')) f.cleaned_data.get('seat'),
f.cleaned_data.get('used_membership'))
except OrderError as e: except OrderError as e:
f.custom_error = str(e) f.custom_error = str(e)
return False return False
+1 -1
View File
@@ -1322,7 +1322,7 @@ class DeviceUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def form_valid(self, form): def form_valid(self, form):
if form.has_changed(): if form.has_changed():
self.object.log_action('pretix.device.changed', user=self.request.user, data={ self.object.log_action('pretix.device.changed', user=self.request.user, data={
k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()] k: form.cleaned_data[k] if k != 'limit_events' else [e.id for e in form.cleaned_data[k]]
for k in form.changed_data for k in form.changed_data
}) })
+36 -62
View File
@@ -89,31 +89,13 @@ logger = logging.getLogger(__name__)
class RecentAuthenticationRequiredMixin: class RecentAuthenticationRequiredMixin:
max_time = 900 max_time = 900
max_form_time = 900
@method_decorator(never_cache) @method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
auth_is_recent = time.time() - request.session.get('pretix_auth_login_time', 0) < self.max_time tdelta = time.time() - request.session.get('pretix_auth_login_time', 0)
allowed_by_token = ( if tdelta > self.max_time:
request.session.pop('pretix_reauthed_flow_token', None) == request.POST.get('flow_token', '')
and request.session.pop('pretix_reauthed_flow_allowed_url', None) == request.get_full_path()
and time.time() - request.session.pop('pretix_reauthed_flow_start_time', 0) < self.max_form_time
)
if auth_is_recent or allowed_by_token:
return super().dispatch(request, *args, **kwargs)
else:
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path())) return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
return super().dispatch(request, *args, **kwargs)
def get_flow_token(self):
self.request.session['pretix_reauthed_flow_allowed_url'] = self.request.get_full_path()
self.request.session['pretix_reauthed_flow_token'] = get_random_string(22)
self.request.session['pretix_reauthed_flow_start_time'] = time.time()
return self.request.session['pretix_reauthed_flow_token']
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['flow_token'] = self.get_flow_token()
return ctx
class ReauthView(TemplateView): class ReauthView(TemplateView):
@@ -301,7 +283,6 @@ class UserHistoryView(ListView):
class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView): class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
max_time = 7200
template_name = 'pretixcontrol/user/2fa_main.html' template_name = 'pretixcontrol/user/2fa_main.html'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -484,15 +465,25 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
notices = [ notices = [
_('A new two-factor authentication device has been added to your account.') _('A new two-factor authentication device has been added to your account.')
] ]
activate = request.POST.get('activate', '')
if activate == 'on' and not self.request.user.require_2fa:
self.request.user.require_2fa = True
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
notices.append(
_('Two-factor authentication has been enabled.')
)
self.request.user.send_security_notice(notices) self.request.user.send_security_notice(notices)
self.request.user.update_session_token() self.request.user.update_session_token()
update_session_auth_hash(self.request, self.request.user) update_session_auth_hash(self.request, self.request.user)
messages.success(request, str(_('The device has been verified and can now be used.'))) note = ''
if self.request.user.require_2fa: if not self.request.user.require_2fa:
return redirect(reverse('control:user.settings.2fa')) note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your '
else: 'account using the buttons below to make a second factor required for logging '
return redirect(reverse('control:user.settings.2fa.enable')) 'into your account.'))
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
return redirect(reverse('control:user.settings.2fa'))
except Exception: except Exception:
messages.error(request, _('The registration could not be completed. Please try again.')) messages.error(request, _('The registration could not be completed. Please try again.'))
logger.exception('WebAuthn registration failed') logger.exception('WebAuthn registration failed')
@@ -503,7 +494,6 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateView): class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_confirm_totp.html' template_name = 'pretixcontrol/user/2fa_confirm_totp.html'
max_form_time = 7200 # this should have effectively no timeout, as the user might need to download the 2fa app first
@cached_property @cached_property
def device(self): def device(self):
@@ -524,6 +514,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
token = request.POST.get('token', '') token = request.POST.get('token', '')
activate = request.POST.get('activate', '')
if self.device.verify_token(token): if self.device.verify_token(token):
self.device.confirmed = True self.device.confirmed = True
self.device.save() self.device.save()
@@ -535,15 +526,24 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
notices = [ notices = [
_('A new two-factor authentication device has been added to your account.') _('A new two-factor authentication device has been added to your account.')
] ]
if activate == 'on' and not self.request.user.require_2fa:
self.request.user.require_2fa = True
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
notices.append(
_('Two-factor authentication has been enabled.')
)
self.request.user.send_security_notice(notices) self.request.user.send_security_notice(notices)
self.request.user.update_session_token() self.request.user.update_session_token()
update_session_auth_hash(self.request, self.request.user) update_session_auth_hash(self.request, self.request.user)
messages.success(request, str(_('The device has been verified and can now be used.'))) note = ''
if self.request.user.require_2fa: if not self.request.user.require_2fa:
return redirect(reverse('control:user.settings.2fa')) note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your '
else: 'account using the buttons below to make a second factor required for logging '
return redirect(reverse('control:user.settings.2fa.enable')) 'into your account.'))
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
return redirect(reverse('control:user.settings.2fa'))
else: else:
messages.error(request, _('The code you entered was not valid. If this problem persists, please check ' messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
'that the date and time of your phone are configured correctly.')) 'that the date and time of your phone are configured correctly.'))
@@ -576,7 +576,6 @@ class User2FALeaveTeamsView(RecentAuthenticationRequiredMixin, TemplateView):
class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView): class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html' template_name = 'pretixcontrol/user/2fa_enable.html'
max_form_time = 7200 # this should have effectively no timeout, as the user might take some time to print out their emergency codes, and they would become invalid in case of a timeout
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES): if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
@@ -585,39 +584,14 @@ class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
return redirect(reverse('control:user.settings.2fa')) return redirect(reverse('control:user.settings.2fa'))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
new_tokens = None
try:
static_tokens_device = StaticDevice.objects.get(user=self.request.user, name='emergency')
except StaticDevice.MultipleObjectsReturned:
static_tokens_device = StaticDevice.objects.filter(
user=self.request.user, name='emergency'
).first()
except StaticDevice.DoesNotExist:
static_tokens_device = None
new_tokens = [get_random_string(length=12, allowed_chars='1234567890') for _ in range(10)]
request.session['pretix_2fa_new_emergency_tokens'] = new_tokens
return super().get(request, *args, new_emergency_tokens=new_tokens, static_tokens_device=static_tokens_device, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
notices = [
_('Two-factor authentication has been enabled.')
]
if 'pretix_2fa_new_emergency_tokens' in request.session:
d = StaticDevice.objects.create(user=self.request.user, name='emergency')
for code in request.session['pretix_2fa_new_emergency_tokens']:
d.token_set.create(token=code)
self.request.user.log_action('pretix.user.settings.2fa.regenemergency', user=self.request.user)
notices += [
_('Your two-factor emergency codes have been regenerated.')
]
del request.session['pretix_2fa_new_emergency_tokens']
self.request.user.require_2fa = True self.request.user.require_2fa = True
self.request.user.save() self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user) self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
messages.success(request, _('Two-factor authentication is now enabled for your account.')) messages.success(request, _('Two-factor authentication is now enabled for your account.'))
self.request.user.send_security_notice(notices) self.request.user.send_security_notice([
_('Two-factor authentication has been enabled.')
])
self.request.user.update_session_token() self.request.user.update_session_token()
update_session_auth_hash(self.request, self.request.user) update_session_auth_hash(self.request, self.request.user)
return redirect(reverse('control:user.settings.2fa')) return redirect(reverse('control:user.settings.2fa'))
+11 -5
View File
@@ -25,7 +25,7 @@ import time
from django.conf import settings from django.conf import settings
from django.contrib.auth import login as auth_login from django.contrib.auth import login as auth_login
from django.contrib.gis.geoip2 import GeoIP2 from django.contrib.gis import geoip2
from django.core.cache import cache from django.core.cache import cache
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -63,14 +63,20 @@ def get_user_agent_hash(request):
_geoip = None _geoip = None
def _get_country(request): def get_geoip() -> geoip2.GeoIP2:
# See https://code.djangoproject.com/ticket/36988#ticket
global _geoip global _geoip
if not _geoip: geoip2.SUPPORTED_DATABASE_TYPES.add("Geoacumen-Country")
_geoip = GeoIP2()
if not _geoip:
_geoip = geoip2.GeoIP2()
return _geoip
def _get_country(request):
try: try:
res = _geoip.country(get_client_ip(request)) res = get_geoip().country(get_client_ip(request))
except AddressNotFoundError: except AddressNotFoundError:
return None return None
return res['country_code'] return res['country_code']
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -32,6 +32,7 @@ ausgecheckt
ausgeklappt ausgeklappt
auswahl auswahl
Authentication Authentication
Authenticator
Authenticator-App Authenticator-App
Autorisierungscode Autorisierungscode
Autorisierungs-Endpunktes Autorisierungs-Endpunktes
@@ -130,6 +131,7 @@ Eingangsscan
Einlassbuchung Einlassbuchung
Einlassdatum Einlassdatum
Einlasskontrolle Einlasskontrolle
Einmalpasswörter
einzuchecken einzuchecken
email email
E-Mail-Renderer E-Mail-Renderer
@@ -163,6 +165,7 @@ Explorer
FA FA
Favicon Favicon
F-Droid F-Droid
freeOTP
Footer Footer
Footer-Link Footer-Link
Footer-Text Footer-Text
@@ -557,6 +560,7 @@ Zahlungs-ID
Zahlungspflichtig Zahlungspflichtig
Zehnerkarten Zehnerkarten
Zeitbasiert Zeitbasiert
zeitbasierte
Zeitslotbuchung Zeitslotbuchung
Zimpler Zimpler
ZIP-Datei ZIP-Datei
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ ausgecheckt
ausgeklappt ausgeklappt
auswahl auswahl
Authentication Authentication
Authenticator
Authenticator-App Authenticator-App
Autorisierungscode Autorisierungscode
Autorisierungs-Endpunktes Autorisierungs-Endpunktes
@@ -130,6 +131,7 @@ Eingangsscan
Einlassbuchung Einlassbuchung
Einlassdatum Einlassdatum
Einlasskontrolle Einlasskontrolle
Einmalpasswörter
einzuchecken einzuchecken
email email
E-Mail-Renderer E-Mail-Renderer
@@ -163,6 +165,7 @@ Explorer
FA FA
Favicon Favicon
F-Droid F-Droid
freeOTP
Footer Footer
Footer-Link Footer-Link
Footer-Text Footer-Text
@@ -557,6 +560,7 @@ Zahlungs-ID
Zahlungspflichtig Zahlungspflichtig
Zehnerkarten Zehnerkarten
Zeitbasiert Zeitbasiert
zeitbasierte
Zeitslotbuchung Zeitslotbuchung
Zimpler Zimpler
ZIP-Datei ZIP-Datei
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-30 11:25+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-03-18 12:23+0000\n" "PO-Revision-Date: 2026-03-30 03:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n" "Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/es/>\n" "js/es/>\n"
@@ -329,7 +329,7 @@ msgstr "Pedido no aprobado"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:68 #: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:68
msgid "Checked-in Tickets" msgid "Checked-in Tickets"
msgstr "Registro de código QR" msgstr "Billetes registrados"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:69 #: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:69
msgid "Valid Tickets" msgid "Valid Tickets"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-02-10 16:49+0000\n" "PO-Revision-Date: 2026-03-25 14:14+0000\n"
"Last-Translator: Raffaele Doretto <ced@comune.portogruaro.ve.it>\n" "Last-Translator: Pietro Isotti <isottipietro@gmail.com>\n"
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix-"
"js/it/>\n" "js/it/>\n"
"Language: it\n" "Language: it\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.15.2\n" "X-Generator: Weblate 5.16.2\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -310,9 +310,8 @@ msgid "Ticket code revoked/changed"
msgstr "Codice biglietto annullato/modificato" msgstr "Codice biglietto annullato/modificato"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:63 #: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:63
#, fuzzy
msgid "Ticket blocked" msgid "Ticket blocked"
msgstr "Biglietto non pagato" msgstr "Biglietto bloccato"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:64 #: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:64
msgid "Ticket not valid at this time" msgid "Ticket not valid at this time"
@@ -429,7 +428,7 @@ msgstr ""
#: pretix/static/pretixbase/js/asynctask.js:276 #: pretix/static/pretixbase/js/asynctask.js:276
msgid "If this takes longer than a few minutes, please contact us." msgid "If this takes longer than a few minutes, please contact us."
msgstr "" msgstr "Se questa operazione richiede alcuni minuti, si prega di contattarci."
#: pretix/static/pretixbase/js/asynctask.js:331 #: pretix/static/pretixbase/js/asynctask.js:331
msgid "Close message" msgid "Close message"
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-02-23 10:00+0000\n" "PO-Revision-Date: 2026-03-23 21:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n" "Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-"
"js/ja/>\n" "js/ja/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.16\n" "X-Generator: Weblate 5.16.2\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -60,7 +60,7 @@ msgstr "PayPal後払い"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41
msgid "iDEAL | Wero" msgid "iDEAL | Wero"
msgstr "" msgstr "iDEAL | Wero"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42
msgid "SEPA Direct Debit" msgid "SEPA Direct Debit"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-01-26 22:00+0000\n" "PO-Revision-Date: 2026-03-25 08:00+0000\n"
"Last-Translator: Renne Rocha <renne@rocha.dev.br>\n" "Last-Translator: Renne Rocha <renne@rocha.dev.br>\n"
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/" "Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"
"pretix/pretix-js/pt_BR/>\n" "pretix/pretix-js/pt_BR/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n" "Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.15.2\n" "X-Generator: Weblate 5.16.2\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -60,7 +60,7 @@ msgstr "PayPal Pay Later"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41
msgid "iDEAL | Wero" msgid "iDEAL | Wero"
msgstr "" msgstr "iDEAL | Wero"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42
msgid "SEPA Direct Debit" msgid "SEPA Direct Debit"
File diff suppressed because it is too large Load Diff

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