Compare commits

..
Author SHA1 Message Date
rash acd050456e fix vite plugin css extraction and entry names 2026-10-03 10:55:06 +02:00
rash baaea0109d make vite hmr support plugin alias 2026-10-03 10:55:06 +02:00
Raphael Michel dd9d59d4bf Fix issues 2026-10-03 10:50:03 +02:00
Raphael Michel 4db39b0b10 Fixup incorrect merge 2026-10-03 10:37:35 +02:00
Raphael Michel 56b6147535 Move default statistics to plugin 2026-10-03 10:33:13 +02:00
Raphael Michel 752db242d8 Max height for timeline 2026-10-03 10:32:05 +02:00
Raphael Michel c43875b22c Move welcome widget 2026-10-03 10:31:46 +02:00
Raphael Michel e2de10f10f Replace checkin widget 2026-10-03 10:31:46 +02:00
Raphael Michel 930b329a62 Replace quota widgets 2026-10-03 10:31:46 +02:00
Raphael Michel 985a51300b Pretty loading state for logs 2026-10-03 10:31:46 +02:00
Raphael Michel 20246ff533 Move event_dashboard_top to partial 2026-10-03 10:31:46 +02:00
Raphael Michel ce433f6c7d Move comment form 2026-10-03 10:31:45 +02:00
Raphael Michel 157bb10f7e Show timeline on subevent detail page 2026-10-03 10:30:59 +02:00
rash 55434445da add new signal so reports can render just where event dashboard is 2026-10-03 10:30:58 +02:00
84858bc048 Allow mails to be sent to addon-attendees (Z#23213551) (#6235)
Remove restrictions that prevent mails to be sent to addon-product-attendees while reducing amount of mails sent to the same email-addresses

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-10-01 13:13:55 +02:00
Raphael Michel e8c091741b Invoice export: Fix crash PRETIXEU-FGE 2026-10-01 11:43:36 +02:00
Richard Schreiber ebdfd21b0f Fix JavaScript error description passing HTML (#6630) 2026-10-01 11:28:36 +02:00
pajowuandRichard Schreiber ed0b3cab7f Fix addon legend overlapping with profile checkbox (Z#23247075) (#6568)
* Fix addon legend overlapping with profile checkbox (Z#23247075)

* Update src/pretix/static/pretixpresale/scss/_checkout.scss

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-10-01 09:49:23 +02:00
Richard Schreiber 3dd8cc5862 PDF Editor: select newly created element after insert (#6596)
* PDF Editor: select newly created element after insert

* Do not create so many savepoints
2026-09-30 12:19:03 +02:00
dependabot[bot] 23e9ca8f34 Bump brace-expansion (#6625)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 5.0.4 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.12)

Updates `brace-expansion` from 2.0.2 to 2.1.7
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-30 10:53:41 +02:00
Raphael Michel e19afd39fb Bump version to 2026.9.0.dev0 2026-09-30 10:50:09 +02:00
Raphael Michel f7863c9fee Bump version to 2026.8.0 2026-09-30 10:50:03 +02:00
Raphael Michel fc3150fecc Revert "DE: Update Wordlist"
This reverts commit 89318bcc24.
2026-09-30 10:49:57 +02:00
Martin Gross 89318bcc24 DE: Update Wordlist 2026-09-30 10:24:13 +02:00
dependabot[bot] a388391f06 Update pyjwt requirement from ==2.14.* to ==2.15.* (#6620) 2026-09-30 10:12:38 +02:00
Raphael Michel b4d000379c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Phin Wolkwitz ba9bba49c6 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Daniel Musketa 824624cce6 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Phin Wolkwitz 8601f62581 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Daniel Musketa 7547d64dea Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Matthias Schrumpf d1186c83ef Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel e4782ff217 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Matthias Schrumpf 3328dc28c7 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel 6c83b4288a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel 6ff37796c3 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel fa60c320b9 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel ca9d9faef4 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel 9718d1ee83 Translations: Update wordlist 2026-09-30 10:09:23 +02:00
pajowu 7bdb2c1555 Email: always use quoted-printable (#6617) 2026-09-30 09:54:21 +02:00
Raphael Michel da6753b53f Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@pretix.eu>
2026-09-30 09:26:26 +02:00
CVZ-es 33df9d13be Translations: Update French
Currently translated at 100.0% (6419 of 6419 strings)

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

powered by weblate
2026-09-30 09:25:43 +02:00
Nate Horst 582f9ac642 Translations: Update Thai
Currently translated at 81.1% (5206 of 6419 strings)

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

powered by weblate
2026-09-30 09:25:00 +02:00
Translate pretix user 594 5ce2957965 Translations: Update Russian
Currently translated at 20.0% (1285 of 6419 strings)

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

powered by weblate
2026-09-30 09:25:00 +02:00
Ыукпун eb9d6ad9a3 Translations: Update Russian
Currently translated at 20.0% (1285 of 6419 strings)

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

powered by weblate
2026-09-30 09:25:00 +02:00
Raphael Michel d339e1d594 Hotfix: Perform validation on the selected step, not on all steps before 2026-09-29 20:11:51 +02:00
Raphael Michel cb15559ac6 Scheduled export: Fix copying of email subject and text (Z#23221224) (#6616) 2026-09-29 16:44:42 +02:00
pajowu 3c95f1fee7 Add HERMA 9012 90 x 90mm Badge format (Z#23248189) (#6619) 2026-09-29 16:44:02 +02:00
Richard Schreiber 6a380c9356 Presale: add js-helper to disable submit for x seconds (#6592)
* Presale: add js-helper to disable submit for x seconds

* Remove disabled-class from button, if any

* Use Intl.RelativeTimeFormat for time formatting
2026-09-29 16:36:09 +02:00
Kara Engelhardt eeea01b31a Fix linter errors 2026-09-29 15:34:26 +02:00
Kara Engelhardt 470621b5cb Add missing licenseheader 2026-09-29 15:34:23 +02:00
dependabot[bot] 5802153101 Update sentry-sdk requirement from ==2.69.* to ==2.70.* (#6600)
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.69.0...2.70.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.70.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-09-29 15:33:24 +02:00
Nate Horst e9f89d5d92 Translations: Update Thai
Currently translated at 70.0% (4494 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Nate Horst 695689ed06 Translations: Update Thai
Currently translated at 98.8% (257 of 260 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Nate Horst 12d405d861 Translations: Update Thai
Currently translated at 69.5% (4463 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Nate Horst 649eee3025 Translations: Update Thai
Currently translated at 65.8% (4230 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Tim 149b4f23aa Translations: Update Spanish
Currently translated at 100.0% (6419 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Nate Horst fa40ccc623 Translations: Update Thai
Currently translated at 55.9% (3593 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Mira Weller 2ba74b1697 Fix various redirects for events on MODE_ORG_ALT_DOMAIN 2026-09-29 14:34:49 +02:00
Mira Weller e54cd6af44 Fix redirect to customer login page when:
- event on custom domain (MODE_EVENT_DOMAIN or MODE_ORG_ALT_DOMAIN)
- organizer on system domain
2026-09-29 14:34:49 +02:00
Mira Weller d2b58d428e Fix customer logout when:
- event on custom domain (MODE_EVENT_DOMAIN or MODE_ORG_ALT_DOMAIN)
- organizer on system domain
2026-09-29 14:34:49 +02:00
Raphael Michel 74332faa7b [SECURITY] API: Fix session validation for uploaded files (CVE-2026-101269, Z#23247174) 2026-09-29 14:30:54 +02:00
Mira Weller bbf391f7d5 Block out of bounds image crop dimensions (Z#23245937 / PRT-009) 2026-09-29 14:30:54 +02:00
Mira Weller d78d5b52fa Prevent parsing non-standard-compliant JSON float values (Z#23245937 / PRT-021) 2026-09-29 14:30:54 +02:00
Mira Weller 9c0fef3d72 Fix potential infinite loop in pdf render (Z#23245937 / PRT-021) 2026-09-29 14:30:54 +02:00
Mira Weller c7d6630979 Fix inefficient loop in compute_validity (Z#23245937 / PRT-013) 2026-09-29 14:30:53 +02:00
Raphael Michel 4edd3c4654 [SECURITY] OAuth: Disable existing tokens when deactivating Application (CVE-2026-101271, Z#23247296) 2026-09-29 14:30:53 +02:00
Mira Weller b1ae638394 [SECURITY] Escape help texts (CVE-2026-101270) 2026-09-29 14:30:53 +02:00
Raphael Michel ae9bb68645 [SECURITY] Fix customer session fixation on cross-domain login (CVE-2026-101268, Z#23247268) 2026-09-29 14:30:53 +02:00
Raphael Michel 48f5a7c8cc [SECURITY] Fix information leak in widgets.json on dashboard (CVE-2026-101267, Z#23247172)
Thanks to Wenhao Wu, Southeast University
2026-09-29 14:30:53 +02:00
Raphael Michel d43032274b [SECURITY] Fix checkout validation bypass (CVE-2026-101266, Z#23245008) 2026-09-29 14:30:53 +02:00
Raphael Michel ae9a744fd9 Revert "Always base64-encode email attachments (Z#23242540) (#6476)"
This reverts commit ff4d3cd403.
2026-09-29 14:07:57 +02:00
pajowu ff4d3cd403 Always base64-encode email attachments (Z#23242540) (#6476)
It might sound unneccesary to base64-encode plaintext attachments, but some smtp providers modify them otherwise
2026-09-29 12:27:43 +02:00
pajowu 3440ee16f2 Plugin List: Apply search filter if prefilled by browser (#6543)
* Plugin List: Apply searchfilter if prefilled by browser

Browsers often prefill the form fields, e.g. with their old content when reloading or going back from the previous page. However these filters were not applied until you changed one of the form fields

* Fix linting errors in plugins.js

* Apply review suggestions
2026-09-29 11:51:29 +02:00
pajowu 43e5b62db3 Banktransfer: Make actionvie atomic (Z#23246414) (#6604) 2026-09-29 11:51:12 +02:00
dependabot[bot] b249c7b417 Bump markdown from 3.10.3 to 3.11 (#6608)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.10.3 to 3.11.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.10.3...3.11.0)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: '3.11'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 10:17:48 +02:00
dependabot[bot] b68c324389 Update flake8 requirement from ==7.3.* to ==7.4.* (#6609)
Updates the requirements on [flake8](https://github.com/pycqa/flake8) to permit the latest version.
- [Commits](https://github.com/pycqa/flake8/compare/7.3.0...7.4.1)

---
updated-dependencies:
- dependency-name: flake8
  dependency-version: 7.4.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 10:17:29 +02:00
Aodhán Burke 4b320b6dab Fix typo: then -> than (#6615) 2026-09-29 10:16:54 +02:00
Richard Schreiber df74cbf5fc Remove Vue2-based widget (#6610)
* Remove Vue2-based widget

* move floatformat.js

* Delete docready.js

* Update widget.py
2026-09-29 10:14:21 +02:00
pajowu c875d758f9 Fix crash on too old time machine date (Z#23245824) (#6599)
* Fix crash on too old time machine date (Z#23245824)

dateutil.parser seems to reject dates with second-precision timezone-offsets, which datetime produces for some dates aroung the year 200. datetime.fromisoformat has no problem parsing them

* Review comments

* Add min date
2026-09-28 17:28:26 +02:00
Martin Gross 2ebdd048c2 PayPal2: Drop maximum length of client ID (Rel: #6424) 2026-09-28 16:12:55 +02:00
Raphael Michel ca40b09080 Fix API documentation bug 2026-09-28 15:46:41 +02:00
pajowuandRaphael Michel 558bf910fd Use fragment_product_list in voucher redemption view (Z#23246929) (#6567)
* Use fragment_product_list in voucher redemption view (Z#23246929)

* Formatting

* Fix usage in templates

* Review comments

* handle form prefix in fragment_product_list.html

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-09-28 13:01:15 +02:00
pajowu 7e1e472691 Monkeypatch stock csrfmiddleware (#6401)
* Monkeypatch stock csrfmiddleware

* Add test for ensure_csrf_cookie
2026-09-28 11:46:23 +02:00
Richard Schreiber b06c9ef463 Control: do not fail on missing reason when user confirm (#6602) 2026-09-28 11:43:52 +02:00
pajowu 2af8d29ca9 Add pdf render tests (#6566)
* Add pdf tests

* pdf tests: Save temporary pdfs for failed test debugging

* Review comments
2026-09-25 16:54:58 +02:00
Raphael Michel 2791d5e49e Celery: Fix missing log if workers are recycled due to RAM usage (#6588) 2026-09-25 08:56:49 +02:00
Richard Schreiber ed68719731 Widget: fix calendar view showing old events due to non-unique key (Z#23247433) (#6585)
* Widget: fix calendar view showing old events due to cache-key (Z#23247433)

* Update EventCalendarCell.vue
2026-09-24 12:00:57 +02:00
9aeb4b9731 Organizer API: Add endpoint for event-meta-properties
* Add API-endpoint for event-meta-properties

* Add new doc-file to index, fix spelling and description

* Fix logentry

* Fix logentry again

* validate and add tests

* add meta_properties from organizer only

* fix choices validation

* filter unknown keys from choices due to django-formsets

* Apply batched suggestions from code review

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

* add safe-guard normalization to None to to_representation

* Apply batched suggestions from code review

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

* update tests to check for error-messages as well

* fix flake8

* fix permission tests

* Make ObjectListField more flexibel for re-use

* fix validation result

* Improve validation

* Change to I18nField for validation

* update MetaPropertyDictField

* fix docs for i18n strings

* make label_child configurable if MetaPropertyDictField should contain non-localized stuff

* undo test changes in events test

* fix flake8

* Apply batched suggestions from code review

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

* improve code formatting

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-09-24 10:51:23 +02:00
Minding 9324887790 Translate OAuth buttons (#6590)
* Translate OAuth buttons

* Revert .po changes
2026-09-24 09:52:47 +02:00
Nate Horst d353384555 Translations: Update Thai
Currently translated at 53.0% (3407 of 6419 strings)

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

powered by weblate
2026-09-24 09:16:49 +02:00
Raphael Michel b8f8e49cce API: Add additional tests for modifying meta data (Z#23247634) 2026-09-23 19:01:52 +02:00
Raphael Michel 806d0a5748 User details: Show list of 2FA devices and allow to reset drift (#6569)
* User details: Show list of 2FA devices

* Add reset button

* Reset throttle

* Refactoring

* Fix delete paths in tests
2026-09-23 17:51:10 +02:00
Raphael Michel 69e541e5af Product list: Fix edge case in dependent availabilities (Z#23243701) (#6532) 2026-09-23 17:22:54 +02:00
Raphael Michel 0723e6015c Event header: Remove date directly above other date (Z#23245589) (#6524)
* Event header: Fix wrapping of date in h2 header (Z#23245589)

This was already correct if the header was a h1

* Revert "Event header: Fix wrapping of date in h2 header (Z#23245589)"

This reverts commit 3d25316fe3.

* Event header: Remove date directly above other date

This removes the date from the <h2> option used when a header image is
uploaded and the header should still be shown. Rendering the date here
makes little sense, since the infobox with the date is always just a few
lines below. It still makes sense for the <h1> option where it becomes
part of the site header.
2026-09-23 15:54:47 +02:00
Raphael Michel b8e1ab8258 Voucher API: Add search parameter (Z#23240589) (#6523) 2026-09-23 15:54:44 +02:00
Raphael Michel 81764278ae Email settings: Collapse attendee texts if unused (Z#23243417) (#6535) 2026-09-23 14:00:56 +02:00
Nate Horst f3068e627a Translations: Update Thai
Currently translated at 52.2% (3356 of 6419 strings)

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

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 993a3f96e9 Translations: Update Thai
Currently translated at 52.1% (3346 of 6419 strings)

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

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 1af0b5e442 Translations: Update Thai
Currently translated at 51.8% (3330 of 6419 strings)

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

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 71b6bf630f Translations: Update Thai
Currently translated at 45.6% (2930 of 6419 strings)

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

powered by weblate
2026-09-23 11:41:45 +02:00
Raphael MichelandMartin Gross 59026da06b Delete PayPal integration v1 (#6530)
* Delete PayPal integration v1

Leaving only migrations behind

* Try to fix CI

* Fix tests some more

* Rename more plugin name instances in tests and docs

---------

Co-authored-by: Martin Gross <martin@pc-coholic.de>
2026-09-23 11:38:16 +02:00
Raphael Michel b3e0892d76 Conditionally import sentry SDK during error page rendering 2026-09-23 11:33:05 +02:00
Raphael Michel 3029a6839b Prevent Sentry SDK from being loaded during tests 2026-09-23 11:29:28 +02:00
Raphael Michel a826b0a1bd Customer area: Hide password change link for SSO users (fixes #6572) (#6580) 2026-09-22 15:22:43 +02:00
Raphael Michel 7d7474c07d Refund form: Do not allow individual negative amounts (#6579) 2026-09-22 12:47:06 +02:00
Raphael Michel 58023381b9 Merge branch 'refund-offset-perm-check' into 'master'
Offset refund: Do not allow to offset to order without access to

See merge request pretix/pretix!52
2026-09-22 12:46:20 +02:00
Raphael Michel e5c44e7aed Offset refund: Do not allow to offset to order without access to 2026-09-22 12:46:20 +02:00
Raphael Michel d77a179606 2FA: Show info on throttled request (#6581)
* 2FA: Show info on throttled request

* Round up the time
2026-09-22 10:38:26 +02:00
Raphael Michel 29614db1e2 InvoiceShredder: Fix shredding of new fields (#6577)
* InvoiceShredder: Fix shredding of new fields

* Update src/pretix/base/migrations/0311_fix_unshredded_invoices.py
2026-09-22 10:37:58 +02:00
Raphael Michel 365051cc21 Event metadata: Fix server-side validation of write protection (#6578) 2026-09-22 10:00:15 +02:00
Raphael Michel 5c6b0eef6f Plugin configuration: Allow organizers to see all events (#6576) 2026-09-22 09:51:18 +02:00
Raphael Michel bc0a6b662b Checkout: Fix VAT ID revalidation after country change (#6575) 2026-09-22 09:51:10 +02:00
dependabot[bot]andRichard Schreiber 0bc1aed1a0 Update pypdf requirement from ==6.11.* to ==6.19.* (#6570)
* Update pypdf requirement from ==6.11.* to ==6.18.*

Updates the requirements on [pypdf](https://github.com/py-pdf/pypdf) to permit the latest version.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.11.0...6.18.1)

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

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

* Update pyproject.toml

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-22 09:38:28 +02:00
Rita Gimenez 0eaa5a081c Translations: Update Catalan
Currently translated at 29.4% (1892 of 6419 strings)

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

powered by weblate
2026-09-21 14:56:27 +02:00
Nate Horst 688c434c4f Translations: Update Thai
Currently translated at 37.1% (2387 of 6419 strings)

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

powered by weblate
2026-09-21 14:56:27 +02:00
dependabot[bot]andRaphael Michel c0474604a1 Update isort requirement from ==8.0.* to ==9.0.* (#6510)
* Update isort requirement from ==8.0.* to ==9.0.*

Updates the requirements on [isort](https://github.com/PyCQA/isort) to permit the latest version.
- [Release notes](https://github.com/PyCQA/isort/releases)
- [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PyCQA/isort/compare/8.0.0...9.0.1)

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

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

* Run isort

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-09-21 14:56:14 +02:00
dependabot[bot] 1741d08c2c Update cryptography requirement from >=50.0.0 to >=50.0.1 (#6509)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/50.0.0...50.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.1
  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-09-21 14:14:06 +02:00
dependabot[bot] 6bbd808aaa Bump postcss-selector-parser from 7.1.1 to 7.1.5 (#6513)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 7.1.1 to 7.1.5.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/v7.1.1...7.1.5)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version: 7.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:13:46 +02:00
dependabot[bot] aa90c5b3b1 Bump @humanfs/node from 0.16.7 to 0.16.8 (#6517)
Bumps [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node) from 0.16.7 to 0.16.8.
- [Release notes](https://github.com/humanwhocodes/humanfs/releases)
- [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node)

---
updated-dependencies:
- dependency-name: "@humanfs/node"
  dependency-version: 0.16.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:13:39 +02:00
dependabot[bot] bfbabe0e58 Update fakeredis requirement from ==2.37.* to ==2.38.* (#6563)
Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.37.0...v2.38.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:08:32 +02:00
Raphael Michelandpajowu aa14505d2c Money representation in templates: Allow more precision (#6454)
* Money representation in templates: Allow more precision

When rendering money in templates, we used to have the following logic:

- When the decimal places fit the currency, render with Babel
- When they don't, e.g. we stored 123.67 JPY, even though there are no
  fractional Yens, render without Babel with a custom format, but render
  the fractional Yens because we'd rather *show* wrong data and make the
  bug obvious than hide it.

However, we only did that up to a prevision of two places, we never
showed more. This is still sufficient for core pretix, but we have
plugins that need to operate in fractional cents. Also, we CAN render
everything through babel for consistent formatting.

There is one **risk**: This might cause weird results on SQLite. Since
SQLite has no concept of precise decimal math, results of in-SQL
computations can sometimes experience floating point errors and show
with A LOT of decimal palces. This used to be invisible since the UI
performed the rounding. With this PR – not any more. We'll need to see
how annoying it is, but it should only affect development mode.

This PR also fixes a bug in tax_rate_format that for some reason did not
do what it was supposed to do, even though I tested it back then, weird.
Might even be a Python version thing?

* Update src/pretix/base/templatetags/money.py

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Apply suggestion from @pajowu

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Fix typing stuff

* Fix precision issue

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-09-18 11:49:12 +02:00
Kara Engelhardt df69656364 PDF: Fix background page being rotated multiple times
transfer_rotation_to_content (or rather add_transformation/replace_content) do not properly duplicate the contentstream they're modifying. this means that if you pass a pdf-level copy of the same bg_page multiple times, the content will be rotated multiple times but the rotation-property ('/Rotate') will only be changed for the first page

pypdfs docs explicitly say that you should not to add_transformation + merge, but instead use merge_transformed_page, which this now does for everything
2026-09-18 11:35:01 +02:00
Kara Engelhardt 82cffd1519 pdf: calculate sizebox based on cropbox and mediabox 2026-09-18 11:33:34 +02:00
Richard Schreiber 7fc527135d PDF: use artbox before trimbox before mediabox from background-PDF as size for canvas 2026-09-18 11:33:34 +02:00
Raphael Michel 8e39b67ee9 Fix missing translatable field label 2026-09-17 11:56:12 +02:00
dependabot[bot] 289cbe5bfc Update django-countries requirement from ==9.0.* to ==9.1.* (#6562)
Updates the requirements on [django-countries](https://github.com/SmileyChris/django-countries) to permit the latest version.
- [Changelog](https://github.com/SmileyChris/django-countries/blob/main/CHANGES.md)
- [Commits](https://github.com/SmileyChris/django-countries/compare/v9.0.0...v9.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 10:43:55 +02:00
Raphael Michel 7143b877c8 Translations: Update German
Currently translated at 100.0% (6419 of 6419 strings)

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

powered by weblate
2026-09-16 15:46:28 +02:00
dependabot[bot] e2a78100af Update pyjwt requirement from ==2.13.* to ==2.14.* (#6557)
Updates the requirements on [pyjwt](https://github.com/jpadilla/pyjwt) to permit the latest version.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.13.0...2.14.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.14.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-09-16 15:46:16 +02:00
dependabot[bot] ca77467e48 Update sentry-sdk requirement from ==2.68.* to ==2.69.* (#6556)
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.68.0...2.69.1)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.69.1
  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-09-16 15:46:07 +02:00
Raphael Michel d3cbfcd4da Invoice export: Add transmission-specific data (Z#23239732) (#6522) 2026-09-15 16:44:00 +02:00
324 changed files with 31417 additions and 31109 deletions
+1 -2
View File
@@ -16,8 +16,6 @@ recursive-include src/pretix/plugins/banktransfer/templates *
recursive-include src/pretix/plugins/banktransfer/static *
recursive-include src/pretix/plugins/manualpayment/templates *
recursive-include src/pretix/plugins/manualpayment/static *
recursive-include src/pretix/plugins/paypal/templates *
recursive-include src/pretix/plugins/paypal/static *
recursive-include src/pretix/plugins/paypal2/templates *
recursive-include src/pretix/plugins/paypal2/static *
recursive-include src/pretix/plugins/src/pretixdroid/templates *
@@ -44,6 +42,7 @@ recursive-include src *.py
recursive-include src *.svg
recursive-include src *.txt
recursive-include src Makefile
recursive-include src *.pdf
recursive-exclude doc *
recursive-exclude deployment *
+2 -2
View File
@@ -53,6 +53,8 @@ Checking a ticket in
Defaults to ``false`` in which case the server will determine the language (currently
the event default language, might change in the future with support for the
``Accept-Language`` header).
:<json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
object will not reflect the simulated changes.
:>json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"``
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
@@ -71,8 +73,6 @@ Checking a ticket in
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
object will not reflect the simulated changes.
**Example request**:
+241
View File
@@ -0,0 +1,241 @@
Event Meta Properties
=====================
Resource description
--------------------
An event meta property is used to to define meta information fields for its events.
This information can be re-used, for example, in ticket layouts.
The event meta property resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Unique ID for this property
name string Name of the property
default string Value of the default option
required boolean If ``true``, an event can only be taken live if the
property is set. In event series, it's always optional
to set a value for individual dates
protected boolean If ``true``, the value for an event can only be changed
by organizer-level administrators
filter_public boolean If ``true``, this property will be shown to filter
events in the public event list and calendar
public_label string Public name of the property
filter_allowed boolean If ``true``, this property will be shown to filter
events or reports in the backend, and it can also be
used for hidden filter parameters in the frontend
choices list of objects List of JSON objects representing all permitted values
for this property, or ``null`` for no limitation.
Each choice object has a required internal name named
``key`` and optional public name named ``label``
consisting of a dictionary of i18n string translations
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/
Returns a list of all meta properties for the organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "Color",
"default": "blue",
"required": false,
"protected": false,
"filter_public": false,
"public_label": {},
"filter_allowed": true,
"choices": [
{
"key": "blue",
"label": {
"en": "Blue"
},
}
]
}
]
}
:param organizer: The ``slug`` field of the organizer
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Returns information on one property, identified by its id.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
{
"id": 1,
"name": "Color",
"default": "blue",
"required": false,
"protected": false,
"filter_public": false,
"public_label": {},
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to retrieve
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/event_meta_properties/
Creates a new meta property
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/event_meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "ref-code",
"default": "abcde",
"required": true,
"choices": null
}
**Example response**:
.. sourcecode:: http
{
"id": 2,
"name": "reference",
"default": "abcde",
"required": true,
"protected": false,
"filter_public": false,
"public_label": null,
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:statuscode 201: no error
:statuscode 400: The meta property could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Update a meta property. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide
all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the
fields that you want to change.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/event_meta_properties/2/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"required": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 3,
"name": "reference",
"default": "abcde",
"required": false,
"protected": false,
"filter_public": false,
"public_label": null,
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to modify
:statuscode 200: no error
:statuscode 400: The property could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Delete a meta property.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
+8 -8
View File
@@ -110,7 +110,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"all_sales_channels": false,
@@ -199,7 +199,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"valid_keys": {
@@ -262,7 +262,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -299,7 +299,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -364,7 +364,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -401,7 +401,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -438,7 +438,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
]
}
@@ -475,7 +475,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
],
"all_sales_channels": true,
+1
View File
@@ -12,6 +12,7 @@ at :ref:`plugin-docs`.
organizers
events
subevents
event_meta_properties
taxrules
categories
items
+1
View File
@@ -116,6 +116,7 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1
:query string code: Only show the voucher with the given voucher code.
:query string search: Only show the voucher with the given query found in the code, tag, or comment.
:query integer max_usages: Only show vouchers with the given maximal number of usages.
:query integer redeemed: Only show vouchers with the given number of redemptions. Note that this doesn't tell you if
the voucher can still be redeemed, as this also depends on ``max_usages``. See the
+1 -1
View File
@@ -83,7 +83,7 @@ Dashboards
.. automodule:: pretix.control.signals
:no-index:
:members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top
:members: event_dashboard_statistics, user_dashboard_widgets, event_dashboard_top
Ticket designs
""""""""""""""
-1
View File
@@ -49,7 +49,6 @@ const ignores = globalIgnores([
'src/pretix/static/pretixcontrol/js/ui/editor.js',
'src/pretix/static/pretixcontrol/js/ui/geo.js',
'src/pretix/static/pretixcontrol/js/ui/main.js',
'src/pretix/static/pretixcontrol/js/ui/plugins.js',
'src/pretix/static/pretixcontrol/js/ui/subevent.js',
'src/pretix/static/pretixcontrol/js/ui/variations.js',
'src/pretix/static/pretixcontrol/js/ui/webauthn.js',
+40 -36
View File
@@ -9,8 +9,7 @@
"version": "1.0.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"vue": "^3.5.30",
"vue-slicksort": "^2.0.5"
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -195,16 +194,16 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
@@ -295,29 +294,43 @@
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -1326,16 +1339,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -1684,9 +1697,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2109,16 +2122,16 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
@@ -3381,9 +3394,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4636,15 +4649,6 @@
"vue-eslint-parser": "^10.0.0"
}
},
"node_modules/vue-slicksort": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/vue-slicksort/-/vue-slicksort-2.0.5.tgz",
"integrity": "sha512-fXz1YrNjhUbJK7o0tMk27mIr4pMAZYLSYvtmLazCtfpvz+zafPCn34ILDL8B7hT7WLVZKreYs6JVe5VWymqmzA==",
"license": "MIT",
"peerDependencies": {
"vue": ">=3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1 -2
View File
@@ -27,8 +27,7 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"vue": "^3.5.30",
"vue-slicksort": "^2.0.5"
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+10 -9
View File
@@ -33,14 +33,14 @@ dependencies = [
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=50.0.0",
"cryptography>=50.0.1",
"css-inline==0.21.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*,>=5.2.17",
"django-bootstrap3==26.2",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-countries==9.1.*",
"django-filter==26.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.7",
@@ -67,7 +67,7 @@ dependencies = [
"kombu==5.6.*",
"libsass==0.23.*",
"lxml",
"markdown==3.10.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
"markdown==3.11", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.3.*",
@@ -75,7 +75,7 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.13.*",
"PyJWT==2.15.*",
"phonenumberslite==9.0.*",
"Pillow==12.3.*",
"pretix-plugin-build",
@@ -84,7 +84,7 @@ dependencies = [
"pycountry",
"pycparser==3.0",
"pycryptodome==3.23.*",
"pypdf==6.11.*",
"pypdf==6.19.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
@@ -94,7 +94,7 @@ dependencies = [
"redis==7.4.*",
"reportlab==5.0.*",
"requests==2.34.*",
"sentry-sdk==2.68.*",
"sentry-sdk==2.70.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -112,10 +112,10 @@ dev = [
"aiohttp==3.14.*",
"coverage",
"coveralls",
"fakeredis==2.37.*",
"flake8==7.3.*",
"fakeredis==2.38.*",
"flake8==7.4.*",
"freezegun",
"isort==8.0.*",
"isort==9.0.*",
"pep8-naming==0.15.*",
"potypo",
"pytest-asyncio>=1.4.0",
@@ -129,6 +129,7 @@ dev = [
"pytest==9.1.*",
"playwright",
"responses",
"pypdfium2"
]
[project.entry-points."distutils.commands"]
-1
View File
@@ -26,7 +26,6 @@ ignore =
src/tests/plugins/*
src/tests/plugins/badges/*
src/tests/plugins/banktransfer/*
src/tests/plugins/paypal/*
src/tests/plugins/paypal2/*
src/tests/plugins/pretixdroid/*
src/tests/plugins/stripe/*
+2
View File
@@ -30,3 +30,5 @@ npminstall:
npmbuild:
npm run build
licenseheaders:
licenseheaders -t ../.licenseheader -E .py -x "*/migrations/*.py"
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2026.8.0.dev0"
__version__ = "2026.9.0.dev0"
@@ -19,13 +19,12 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.dispatch import receiver
from pretix.base.signals import register_payment_providers
def get_session_key_for_api_auth(user, auth):
if user.is_authenticated:
return f'api-upload-User-{user.pk}'
else:
return f'api-upload-{str(type(auth))}-{auth.pk}'
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
def register_payment_provider(sender, **kwargs):
from .payment import Paypal
return Paypal
def get_session_key_for_api_request(request):
return get_session_key_for_api_auth(request.user, request.auth)
+4
View File
@@ -101,6 +101,10 @@ class OAuthAccessToken(AbstractAccessToken):
self.expires = now() - timedelta(hours=1)
self.save(update_fields=['expires'])
def is_valid(self, scopes=None):
# Can maybe be removed after upgrading django-oauth-toolkit to 3.4.1
return super().is_valid(scopes) and self.application.is_usable(None)
class OAuthRefreshToken(AbstractRefreshToken):
application = models.ForeignKey(
+1 -1
View File
@@ -979,7 +979,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid',
'reusable_media_usage_enforced',
'system_question_order', # TODO(questionnaires) - remove or replace
'system_question_order',
'tax_rule_payment',
'tax_rule_cancellation',
]
+9 -1
View File
@@ -37,6 +37,8 @@ from collections import OrderedDict
from django.core.exceptions import ValidationError
from rest_framework import serializers
from pretix.api.auth.utils import get_session_key_for_api_request
def remove_duplicates_from_list(data):
return list(OrderedDict.fromkeys(data))
@@ -83,10 +85,16 @@ class UploadedFileField(serializers.Field):
request = self.context.get('request', None)
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_request(request):
self.fail('not_found')
except (ValidationError, IndexError): # invalid uuid
self.fail('not_found')
except CachedFile.DoesNotExist:
+1 -157
View File
@@ -53,7 +53,6 @@ from pretix.base.models import (
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
)
from pretix.base.models.items import Questionnaire, QuestionnaireChild
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
@@ -543,7 +542,6 @@ class LegacyDependencyValueField(serializers.CharField):
class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True)
internal_name = serializers.CharField(allow_null=True, source='question', read_only=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
class Meta:
@@ -552,7 +550,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'internal_name',)
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
@@ -628,160 +626,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
return question
class QuestionRefField(serializers.PrimaryKeyRelatedField):
def to_representation(self, qc):
if not qc:
return None
elif qc.system_datafield:
return qc.system_datafield
elif qc.user_datafield_id:
return qc.user_datafield_id
else:
return None
def to_internal_value(self, data):
if type(data) == int:
return {'user_datafield': super().to_internal_value(data), 'system_datafield': None}
elif type(data) == str or data is None:
return {'user_datafield': None, 'system_datafield': data}
else:
self.fail('incorrect_type', data_type=type(data).__name__)
def use_pk_only_optimization(self):
return self.source == '*'
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
question = QuestionRefField(source='*', queryset=Question.objects.none())
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
class Meta:
model = QuestionnaireChild
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["question"].queryset = self.context["event"].questions.all()
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
def validate(self, data):
data = super().validate(data)
event = self.context['event']
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
raise ValidationError('Dependencies are not supported during check-in.')
dep = full_data.get('dependency_question')
if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
return data
def validate_dependency_question(self, value):
if value:
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
if value == self.instance:
raise ValidationError('A question cannot depend on itself.')
return value
class QuestionnaireSerializer(I18nAwareModelSerializer):
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = Questionnaire
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'children')
def __init__(self, *args, **kwargs):
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
super().__init__(*args, **kwargs)
def validate(self, data):
data = super().validate(data)
event = self.context['event']
#full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
#full_data.update(data)
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
# raise ValidationError('Dependencies are not supported during check-in.')
#if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
# raise ValidationError(_('This type of question cannot be asked during check-in.'))
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
#Question.clean_items(event, full_data.get('items') or [])
return data
def validate_children(self, value):
prev_questions = {}
for child in value:
if child.get('dependency_question'):
if (child['dependency_question']['user_datafield'] or child['dependency_question']['system_datafield']) not in prev_questions:
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
if child['user_datafield']:
prev_questions[child['user_datafield']] = child
if child['system_datafield']:
prev_questions[child['system_datafield']] = child
return value
@transaction.atomic
def create(self, validated_data):
children_data = validated_data.pop('children') if 'children' in validated_data else []
questionnaire = super().create(validated_data)
self.set_children(questionnaire, children_data)
return questionnaire
@transaction.atomic
def update(self, instance, validated_data):
children_data = validated_data.pop('children', None)
questionnaire = super().update(instance, validated_data)
if children_data is not None:
self.set_children(questionnaire, children_data)
return questionnaire
def set_children(self, questionnaire, new_data):
result = []
child_serializer = self.fields['children'].child
existing = questionnaire.children.all()
for i, d in enumerate(new_data):
d['questionnaire'] = questionnaire
d['position'] = i + 1
d.setdefault('required', False)
d.setdefault('help_text', None)
d.setdefault('dependency_question', None)
d.setdefault('dependency_values', None)
updatable = min(len(existing), len(new_data))
for i in range(0, updatable):
result.append(child_serializer.update(existing[i], new_data[i]))
for i in range(updatable, len(new_data)):
result.append(child_serializer.create(new_data[i]))
for i in range(updatable, len(existing)):
existing[i].delete()
return result
class QuotaSerializer(I18nAwareModelSerializer):
available = serializers.BooleanField(read_only=True)
available_number = serializers.IntegerField(read_only=True)
+10 -4
View File
@@ -41,6 +41,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from pretix.api.auth.utils import get_session_key_for_api_request
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.forms import form_field_to_serializer_field
@@ -258,16 +259,21 @@ class AnswerSerializer(I18nAwareModelSerializer):
if data['answer'] == 'file:keep':
return data
try:
ao = self.context["request"].user or self.context["request"].auth
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(ao))}-{ao.pk}',
file__isnull=False,
pk=data['answer'][len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_request(self.context["request"]):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
except (ValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
allowed_types = (
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
+90 -3
View File
@@ -28,6 +28,7 @@ from django.db import transaction
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.translation import gettext, gettext_lazy as _
from i18nfield.rest_framework import I18nField
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -40,9 +41,10 @@ from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.auth import get_auth_backends
from pretix.base.i18n import get_language_without_region
from pretix.base.models import (
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
Customer, Device, EventMetaProperty, GiftCard, GiftCardAcceptance,
GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer,
ReusableMedium, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.permissions import (
@@ -640,3 +642,88 @@ class OrganizerSettingsSerializer(SettingsSerializer):
)
# TODO: make sure pub is always correct
return 'pub/' + fname
class MetaPropertyListField(serializers.ListField):
def __init__(self, *args, **kwargs):
kwargs["validators"] = kwargs.pop("validators", [])
def validate_keys_unique(choices):
if not choices:
return
keys = [c.get("key") for c in choices]
if len(set(keys)) < len(keys):
raise ValidationError("The key for each meta property value option must be unique.")
kwargs["validators"].append(
validate_keys_unique
)
super().__init__(*args, **kwargs)
class MetaPropertyDictField(serializers.DictField):
def __init__(self, **kwargs):
self.label_child = kwargs.pop("label_child", I18nField())
super().__init__(**kwargs)
def to_representation(self, value):
# django added unneccessary keys DELETE, ORDER through formsets, filter them here for backwards compat
d = {
"key": value["key"]
}
if "label" in value:
d["label"] = self.label_child.to_representation(value["label"])
return super().to_representation(d)
def to_internal_value(self, data):
if not isinstance(data, dict):
raise ValidationError("Meta property value options must be a dict.")
if not isinstance(data.get("key"), str):
raise ValidationError("Meta property value options must have a key of type string.")
if any(k not in {"key", "label"} for k in data.keys()):
raise ValidationError("Meta property value options may only have a key and optionally a label.")
if "label" in data:
try:
data["label"] = self.label_child.to_internal_value(data["label"])
except ValidationError as e:
raise ValidationError({"label": e.detail})
return super().to_internal_value(data)
class EventMetaPropertiesSerializer(I18nAwareModelSerializer):
choices = MetaPropertyListField(
child=MetaPropertyDictField(
label_child=I18nField()
),
allow_null=True,
)
class Meta:
model = EventMetaProperty
fields = (
'id', 'name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed',
'choices'
)
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
choices = full_data.get("choices")
default = full_data.get("default")
if choices and default:
choice_keys = [c.get("key") for c in choices]
if default not in choice_keys:
raise ValidationError("You cannot set a default value that is not a valid value.")
if not choices and "choices" in data:
# normalize empty dict to None
data["choices"] = None
return data
+2 -2
View File
@@ -68,6 +68,7 @@ orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportVie
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions')
orga_router.register(r'event_meta_properties', organizer.EventMetaPropertiesViewSet)
team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet)
@@ -79,8 +80,7 @@ event_router.register(r'subevents', event.SubEventViewSet)
event_router.register(r'clone', event.CloneEventViewSet)
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'datafields', item.QuestionViewSet)
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
+8 -1
View File
@@ -50,6 +50,7 @@ from rest_framework.generics import ListAPIView
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from pretix.api.auth.utils import get_session_key_for_api_auth
from pretix.api.serializers.checkin import (
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
@@ -331,10 +332,16 @@ with scopes_disabled():
def _handle_file_upload(data, user, auth):
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_auth(user, auth):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
+1 -48
View File
@@ -48,15 +48,13 @@ from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionnaireSerializer, QuestionOptionSerializer, QuestionSerializer,
QuotaSerializer,
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import Questionnaire
from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.i18n import i18ncomp
@@ -568,51 +566,6 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionnaireSerializer
queryset = Questionnaire.objects.none()
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
#filterset_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
write_permission = 'event.items:write'
def get_queryset(self):
return self.request.event.questionnaires.prefetch_related('children').all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.questionnaire.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.questionnaire.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.questionnaire.deleted',
user=self.request.user,
auth=self.request.auth,
)
super().perform_destroy(instance)
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
+51 -4
View File
@@ -44,15 +44,16 @@ from pretix.api.models import OAuthAccessToken
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.organizer import (
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
EventMetaPropertiesSerializer, GiftCardSerializer,
GiftCardTransactionSerializer, MembershipSerializer,
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer,
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
)
from pretix.base.models import (
Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry,
Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team,
TeamAPIToken, TeamInvite, User,
Customer, Device, Event, EventMetaProperty, GiftCard, GiftCardTransaction,
LogEntry, Membership, MembershipType, Organizer, SalesChannel, SeatingPlan,
Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
@@ -846,3 +847,49 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
data={'id': instance.pk}
)
instance.delete()
class EventMetaPropertiesViewSet(viewsets.ModelViewSet):
serializer_class = EventMetaPropertiesSerializer
queryset = EventMetaProperty.objects.none()
write_permission = 'organizer.settings.general:write'
def get_queryset(self):
return self.request.organizer.meta_properties.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.property.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer_id=self.request.organizer.pk)
serializer.instance.log_action(
'pretix.property.created',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(organizer_id=self.request.organizer.pk)
serializer.instance.log_action(
'pretix.property.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
+2 -1
View File
@@ -33,6 +33,7 @@ from rest_framework.views import APIView
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
from pretix.api.auth.token import TeamTokenAuthentication
from pretix.api.auth.utils import get_session_key_for_api_request
from pretix.base.models import CachedFile
from pretix.helpers.images import (
IMAGE_TYPES, validate_uploaded_file_for_valid_image,
@@ -78,7 +79,7 @@ class UploadView(APIView):
web_download=False,
filename=file_obj.name,
type=content_type,
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}'
session_key=get_session_key_for_api_request(request)
)
cf.file.save(file_obj.name, file_obj)
cf.save()
+4
View File
@@ -40,6 +40,7 @@ with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
code = CharFilter(lookup_expr='iexact')
search = CharFilter(method='search_qs')
class Meta:
model = Voucher
@@ -54,6 +55,9 @@ with scopes_disabled():
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
def search_qs(self, qs, name, value):
return qs.filter(Q(code__icontains=value) | Q(tag__icontains=value) | Q(comment__icontains=value))
class VoucherViewSet(viewsets.ModelViewSet):
serializer_class = VoucherSerializer
+7 -3
View File
@@ -27,7 +27,7 @@ from datetime import timedelta
from functools import cached_property
from typing import List, Optional, Protocol
import sentry_sdk
from django.conf import settings
from django.db import DatabaseError, transaction
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -236,7 +236,9 @@ class OutboundSyncProvider:
# model changes saved by set_sync_error / clear_in_flight calls below
if sq.failed_attempts >= self.max_attempts:
logger.exception('Failed to sync order (max attempts exceeded)')
sentry_sdk.capture_exception(e)
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
sq.set_sync_error("exceeded", e.messages, e.full_message)
else:
logger.info(
@@ -247,7 +249,9 @@ class OutboundSyncProvider:
sq.clear_in_flight()
except Exception as e:
logger.exception('Failed to sync order (unhandled exception)')
sentry_sdk.capture_exception(e)
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
sq.set_sync_error("internal", [], str(e))
@cached_property
+27 -4
View File
@@ -54,6 +54,7 @@ from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable
from ..exporter import BaseExporter, MultiSheetListExporter
from ..invoicing.transmission import get_transmission_types
from ..services.export import ExportError
from ..services.invoices import invoice_pdf_task
from ..signals import (
@@ -197,7 +198,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
def iterate_sheet(self, form_data, sheet):
_ = gettext
if sheet == 'invoices':
yield [
headers = [
_('Invoice number'),
_('Date'),
_('Order code'),
@@ -230,8 +231,18 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Total value (without taxes)'),
_('Payment matching IDs'),
_('Payment providers'),
_('Transmission type'),
_('Transmission status'),
_('Transmission date'),
]
transmission_types = get_transmission_types()
for tt in transmission_types:
for c in tt.describe_info_columns():
headers.append(str(tt.verbose_name) + ': ' + str(c))
yield headers
p_providers = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
@@ -242,7 +253,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
'm'
).order_by()
base_qs = self.invoices_queryset(form_data)\
base_qs = self.invoices_queryset(form_data)
qs = base_qs.select_related(
'order', 'refers'
@@ -280,7 +291,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
if mid:
pmis.append(mid)
pmi = '\n'.join(pmis)
yield [
line = [
i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
@@ -315,8 +326,20 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
', '.join([
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
if p and p != 'free'
])
]),
i.transmission_type_instance.verbose_name,
i.get_transmission_status_display(),
date_format(i.transmission_date, "SHORT_DATETIME_FORMAT") if i.transmission_date else "",
]
for tt in transmission_types:
if tt.identifier == i.transmission_type:
described = dict(tt.describe_info(i.invoice_to_transmission_info, i.invoice_to_country, i.invoice_to_is_business))
for c in tt.describe_info_columns():
line.append(described.get(c, ""))
else:
for c in tt.describe_info_columns():
line.append("")
yield line
elif sheet == 'lines':
yield [
_('Invoice number'),
+155 -117
View File
@@ -36,6 +36,7 @@ import copy
import json
import logging
import re
from collections import namedtuple
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -599,12 +600,16 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
image = ImageOps.exif_transpose(image)
if f._cropdata:
image = image.crop((
f._cropdata.get('x', 0),
f._cropdata.get('y', 0),
f._cropdata.get('x', 0) + f._cropdata.get('width', image.width),
f._cropdata.get('y', 0) + f._cropdata.get('height', image.height),
))
left = int(f._cropdata.get('x', 0))
top = int(f._cropdata.get('y', 0))
right = left + int(f._cropdata.get('width', image.width))
bottom = top + int(f._cropdata.get('height', image.height))
if left >= image.width or top >= image.height or right > image.width or bottom > image.height:
raise ValidationError(
self.error_messages['max_dimension'],
code='max_dimension',
)
image = image.crop((left, top, right, bottom))
with BytesIO() as output:
# This might use a lot of memory, but temporary files are not a good option since
# we don't control the cleanup
@@ -642,22 +647,49 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
super().__init__(*args, **kwargs)
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required help_text container_type', defaults=('', Question.ContainerType.ORDERPOSITION)
)
def get_fake_attendee_questions(settings):
fq = []
sqo = settings.system_question_order
if settings.attendee_names_asked:
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), settings.attendee_names_required))
if settings.attendee_emails_asked:
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), settings.attendee_emails_required))
if settings.attendee_company_asked:
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), settings.attendee_company_required))
if settings.attendee_addresses_asked:
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('state', _('State'), sqo.get('country', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), settings.attendee_addresses_required))
return fq
class BaseQuestionsForm(forms.Form):
"""
This is the base form class responsible for asking order- or ticket-related questions.
"""
address_validation = False
def build_user_question_field(self, request, event, answerlist, qc, datafield):
def build_user_question_field(self, request, event, answerlist, q):
# Do we already have an answer? Provide it as the initial value
answers = [a for a in answerlist if a.question_id == datafield.id]
answers = [a for a in answerlist if a.question_id == q.id]
if answers:
initial = answers[0]
else:
initial = None
tz = ZoneInfo(event.settings.timezone)
required = qc.required and not self.all_optional
if datafield.type == Question.TYPE_BOOLEAN:
required = q.required and not self.all_optional
if q.type == Question.TYPE_BOOLEAN:
if required:
# For some reason, django-bootstrap3 does not set the required attribute
# itself.
@@ -671,105 +703,107 @@ class BaseQuestionsForm(forms.Form):
initialbool = False
field = forms.BooleanField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initialbool, widget=widget,
)
elif datafield.type == Question.TYPE_NUMBER:
elif q.type == Question.TYPE_NUMBER:
field = forms.DecimalField(
label=escape(qc.label), required=required,
min_value=datafield.valid_number_min or Decimal('0.00'),
max_value=datafield.valid_number_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_value=q.valid_number_min or Decimal('0.00'),
max_value=q.valid_number_max,
help_text=rich_text(q.help_text),
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_STRING:
elif q.type == Question.TYPE_STRING:
field = forms.CharField(
label=escape(qc.label), required=required,
min_length=datafield.valid_string_length_min,
max_length=datafield.valid_string_length_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_length=q.valid_string_length_min,
max_length=q.valid_string_length_max,
help_text=rich_text(q.help_text),
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_TEXT:
elif q.type == Question.TYPE_TEXT:
field = forms.CharField(
label=escape(qc.label), required=required,
min_length=datafield.valid_string_length_min,
max_length=datafield.valid_string_length_max,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
min_length=q.valid_string_length_min,
max_length=q.valid_string_length_max,
help_text=rich_text(q.help_text),
widget=forms.Textarea,
initial=initial.answer if initial else None,
)
elif datafield.type == Question.TYPE_COUNTRYCODE:
elif q.type == Question.TYPE_COUNTRYCODE:
field = CountryField(
countries=CachedCountries,
blank=True, null=True, blank_label=' ',
).formfield(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
widget=forms.Select,
empty_label=' ',
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
initial=initial.answer if initial else (
guess_country_from_request(request, event) if required else None),
)
elif datafield.type == Question.TYPE_CHOICE:
elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
queryset=datafield.options,
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
queryset=q.options,
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
widget=forms.Select,
to_field_name='identifier',
empty_label='',
initial=initial.options.first() if initial else None,
)
elif datafield.type == Question.TYPE_CHOICE_MULTIPLE:
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
field = forms.ModelMultipleChoiceField(
queryset=datafield.options,
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
queryset=q.options,
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
to_field_name='identifier',
widget=QuestionCheckboxSelectMultiple,
initial=initial.options.all() if initial else None,
)
elif datafield.type == Question.TYPE_FILE:
if datafield.valid_file_portrait:
elif q.type == Question.TYPE_FILE:
if q.valid_file_portrait:
field = PortraitImageField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initial.file if initial else None,
widget=PortraitImageWidget(answer=initial, request=request, attrs={'data-portrait-photo': 'true'}),
widget=PortraitImageWidget(answer=initial, request=request,
attrs={'data-portrait-photo': 'true'}),
)
else:
field = ExtFileField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=initial.file if initial else None,
widget=UploadedFileWidget(answer=initial, request=request),
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
)
elif datafield.type == Question.TYPE_DATE:
elif q.type == Question.TYPE_DATE:
attrs = {}
if datafield.valid_date_min:
attrs['data-min'] = datafield.valid_date_min.isoformat()
if datafield.valid_date_max:
attrs['data-max'] = datafield.valid_date_max.isoformat()
help_text = qc.help_text
if q.valid_date_min:
attrs['data-min'] = q.valid_date_min.isoformat()
if q.valid_date_max:
attrs['data-max'] = q.valid_date_max.isoformat()
help_text = q.help_text
if not help_text:
if datafield.valid_date_min and datafield.valid_date_max:
if q.valid_date_min and q.valid_date_max:
help_text = format_lazy(
_('Please enter a date between {min} and {max}.'),
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
elif datafield.valid_date_min:
elif q.valid_date_min:
help_text = format_lazy(
_('Please enter a date no earlier than {min}.'),
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
)
elif datafield.valid_date_max:
elif q.valid_date_max:
help_text = format_lazy(
_('Please enter a date no later than {max}.'),
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
if initial and initial.answer:
try:
@@ -779,16 +813,16 @@ class BaseQuestionsForm(forms.Form):
else:
_initial = None
field = forms.DateField(
label=escape(qc.label), required=required,
label=escape(q.question), required=required,
help_text=rich_text(help_text),
initial=_initial,
widget=DatePickerWidget(attrs),
)
if datafield.valid_date_min:
field.validators.append(MinDateValidator(datafield.valid_date_min))
if datafield.valid_date_max:
field.validators.append(MaxDateValidator(datafield.valid_date_max))
elif datafield.type == Question.TYPE_TIME:
if q.valid_date_min:
field.validators.append(MinDateValidator(q.valid_date_min))
if q.valid_date_max:
field.validators.append(MaxDateValidator(q.valid_date_max))
elif q.type == Question.TYPE_TIME:
if initial and initial.answer:
try:
_initial = dateutil.parser.parse(initial.answer).time()
@@ -797,29 +831,29 @@ class BaseQuestionsForm(forms.Form):
else:
_initial = None
field = forms.TimeField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
initial=_initial,
widget=TimePickerWidget(without_seconds=True),
)
elif datafield.type == Question.TYPE_DATETIME:
help_text = qc.help_text
elif q.type == Question.TYPE_DATETIME:
help_text = q.help_text
if not help_text:
if datafield.valid_datetime_min and datafield.valid_datetime_max:
if q.valid_datetime_min and q.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time between {min} and {max}.'),
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
elif datafield.valid_datetime_min:
elif q.valid_datetime_min:
help_text = format_lazy(
_('Please enter a date and time no earlier than {min}.'),
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
)
elif datafield.valid_datetime_max:
elif q.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time no later than {max}.'),
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
if initial and initial.answer:
@@ -831,20 +865,20 @@ class BaseQuestionsForm(forms.Form):
_initial = None
field = SplitDateTimeField(
label=escape(qc.label), required=required,
label=escape(q.question), required=required,
help_text=rich_text(help_text),
initial=_initial,
widget=SplitDateTimePickerWidget(
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
min_date=datafield.valid_datetime_min,
max_date=datafield.valid_datetime_max
min_date=q.valid_datetime_min,
max_date=q.valid_datetime_max
),
)
if datafield.valid_datetime_min:
field.validators.append(MinDateTimeValidator(datafield.valid_datetime_min))
if datafield.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(datafield.valid_datetime_max))
elif datafield.type == Question.TYPE_PHONENUMBER:
if q.valid_datetime_min:
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
if q.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
elif q.type == Question.TYPE_PHONENUMBER:
if initial:
try:
initial = PhoneNumber().from_string(initial.answer)
@@ -857,27 +891,26 @@ class BaseQuestionsForm(forms.Form):
initial = "+{}.".format(phone_prefix)
field = PhoneNumberField(
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field.datafield = datafield
field.question = q
if answers:
# Cache the answer object for later use
field.answer = answers[0]
if qc.dependency_question_id:
field.widget.attrs['data-question-dependency'] = qc.dependency_question_id
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(qc.dependency_values))
if datafield.type != 'M':
field.widget.attrs['required'] = qc.required and not self.all_optional
field._required = qc.required and not self.all_optional
if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional
field.required = False
return field
def check_user_questions(self, d):
@@ -941,7 +974,6 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
super().__init__(*args, **kwargs)
# TODO(questionnaires) - switch olq's to questionnaires !
questions = Question.objects.filter(
event=event, container_type=Question.ContainerType.ORDER,
ask_during_checkin=False, hidden=False,
@@ -976,7 +1008,6 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
orderpos = self.orderpos = kwargs.pop('orderpos', None)
pos = cartpos or orderpos
item = pos.item
questionnaires = pos.item.relevant_questionnaires
event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False)
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
@@ -986,13 +1017,18 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
for questionnaire in questionnaires:
for child in getattr(questionnaire, 'childlist', questionnaire.children.all()):
if child.user_datafield:
df = child.user_datafield
self.fields['question_%s' % df.id] = self.build_user_question_field(request, event, pos.answerlist, child, df)
elif child.system_datafield:
self.fields[child.system_datafield] = self.build_system_question_field(request, event, pos, child)
questions = []
if item.ask_attendee_data:
questions += get_fake_attendee_questions(event.settings)
questions += pos.item.questions_to_ask
questions.sort(key=lambda q: q.position)
for q in questions:
if isinstance(q, FakeQuestion):
self.fields[q.id] = self.build_system_question_field(request, event, pos, q)
else:
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos.answerlist, q)
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
@@ -1057,21 +1093,21 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
)
def build_system_question_field(self, request, event, pos, qc):
field_name = qc.system_datafield
field_name = qc.id
if field_name == 'attendee_name_parts':
return NamePartsFormField(
max_length=255,
required=qc.required and not self.all_optional,
scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.attendee_name_parts,
)
if field_name == 'attendee_email':
return forms.EmailField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.attendee_email,
widget=forms.EmailInput(
@@ -1083,7 +1119,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'company':
return forms.CharField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
max_length=255,
initial=pos.company,
@@ -1092,7 +1128,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'street':
return forms.CharField(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
widget=forms.Textarea(attrs={
'rows': 2,
@@ -1105,7 +1141,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
return forms.CharField(
required=False,
max_length=30,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=pos.zipcode,
widget=forms.TextInput(attrs={
@@ -1115,7 +1151,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == 'city':
return forms.CharField(
required=False,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
max_length=255,
initial=pos.city,
@@ -1129,7 +1165,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
countries=CachedCountries
).formfield(
required=qc.required and not self.all_optional,
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
initial=country,
widget=forms.Select(attrs={
@@ -1157,7 +1193,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
del self.data[fprefix + 'state']
field = forms.ChoiceField(
label=escape(qc.label),
label=escape(qc.question),
help_text=rich_text(qc.help_text),
required=False,
choices=c,
@@ -1170,8 +1206,9 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
return field
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
validate_address,
)
d = super().clean()
@@ -1412,8 +1449,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on'
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
validate_address,
)
data = self.cleaned_data
@@ -1467,7 +1505,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
"vat_id": _("This field is required.")
})
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
if self.validate_vat_id and self.instance.vat_id_validated and not any(v in self.changed_data for v in ('is_business', 'vat_id', 'country')):
pass # Skip re-validation if it is validated
elif self.validate_vat_id and vat_id_applicable:
try:
+2 -2
View File
@@ -82,8 +82,8 @@ class UserSettingsForm(forms.ModelForm):
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
('otp_totp.totpdevice', _('Smartphone with the Authenticator application')),
('pretixbase.webauthndevice', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
+2 -2
View File
@@ -71,8 +71,8 @@ class EmailTransmissionType(TransmissionType):
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return {
"transmission_email_other": bool(transmission_info.get("transmission_email_address")),
"transmission_email_address": transmission_info.get("transmission_email_address"),
"transmission_email_other": bool((transmission_info or {}).get("transmission_email_address")),
"transmission_email_address": (transmission_info or {}).get("transmission_email_address"),
}
def form_data_to_transmission_info(self, form_data: dict) -> dict:
@@ -107,6 +107,9 @@ class TransmissionType:
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info
def describe_info_columns(self):
return [f.label for f in self.invoice_address_form_fields.values()]
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
form_data = self.transmission_info_to_form_data(transmission_info)
data = []
@@ -1,196 +0,0 @@
# Generated by Django 4.2.29 on 2026-03-19 14:24
import json
from collections import namedtuple
from itertools import chain, groupby
from django.db import migrations, models
import django.db.models.deletion
import i18nfield.fields
from i18nfield.strings import LazyI18nString
import pretix.base.models.base
import pretix.base.models.fields
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required'
)
def get_fake_questions(settings):
def b(s):
return s == 'True'
fq = []
sqo = json.loads(settings.get('system_question_order', '{}'))
_ = LazyI18nString.from_gettext
if b(settings.get('attendee_names_asked', 'True')):
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required'))))
if b(settings.get('attendee_emails_asked')):
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required'))))
if b(settings.get('attendee_company_asked')):
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required'))))
if b(settings.get('attendee_addresses_asked')):
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
return fq
def migrate_questions_forward(apps, schema_editor):
Event = apps.get_model("pretixbase", "Event")
Item = apps.get_model("pretixbase", "Item")
Question = apps.get_model("pretixbase", "Question")
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild")
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
def create_grouped_item_questionnaires(event, children, label_prefix, questionnaire_type):
# group by item, creating a unique questionnaire per item
item_questionnaires = (([t[3] for t in children], item_id) for item_id, children in
groupby(children, key=lambda t: t[0]))
# group again, merging all questionnaires with identical children
merged_questionnaires = groupby(sorted(item_questionnaires, key=lambda t: [q.id for q in t[0]]),
key=lambda t: t[0])
for children, iterator in merged_questionnaires:
items = [item for _c, item in iterator]
# create questionnaires and children
questionnaire = Questionnaire.objects.create(
event=event, type=questionnaire_type, position=0, all_sales_channels=True,
internal_name=label_prefix + ', '.join(str(iname or name) for (id, iname, name) in items)
)
questionnaire.items.set([id for (id, iname, name) in items])
deps = {}
for position, child in enumerate(children):
if isinstance(child, FakeQuestion):
QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
system_datafield=child.id,
required=child.required,
label=child.question,
)
else:
deps[child.id] = QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=child,
required=child.required,
label=child.question,
help_text=child.help_text,
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
dependency_values=child.dependency_values,
)
for event in Event.objects.iterator():
# get relevant settings
settings = {
setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=(
'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required',
'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required',
))
}
# get all ticket-level questions (user-defined and system provided), along with the products for which they're asked
questions = event.questions.filter(container_type='P')
children = sorted(chain((
(item, q.position, 0, q)
for q in get_fake_questions(settings)
for item in event.items.filter(personalized=True).values_list('id', 'internal_name', 'name')
), (
(item, q.position, q.id, q)
for q in questions.filter(hidden=False)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, '', 'PS')
children = sorted(chain((
(item, q.position, q.id, q)
for q in questions.filter(hidden=True)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, 'Hidden questions for ', 'PH')
# get all order-level questions
questions = list(event.questions.filter(container_type='O', hidden=False).order_by('position'))
if questions:
# create questionnaires and children
questionnaire = Questionnaire.objects.create(
event=event, type='OS', position=0, all_sales_channels=True,
internal_name='Per-order questions',
)
deps = {}
for position, child in enumerate(questions):
deps[child.id] = QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=child,
required=child.required,
label=child.question,
help_text=child.help_text,
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
dependency_values=child.dependency_values,
)
def migrate_questions_backward(apps, schema_editor):
pass # as long as we don't delete the old columns, this is a no op. after that, it gets complicated...
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0309_alter_questionanswer_unique_together_and_more'),
]
operations = [
migrations.CreateModel(
name='Questionnaire',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('internal_name', models.CharField(max_length=255)),
('type', models.CharField(max_length=5)),
('position', models.PositiveIntegerField(default=0)),
('all_sales_channels', models.BooleanField(default=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')),
('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')),
('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='QuestionnaireChild',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('position', models.PositiveIntegerField(default=0)),
('system_datafield', models.CharField(max_length=25, null=True)),
('required', models.BooleanField(default=False)),
('label', i18nfield.fields.I18nTextField()),
('help_text', i18nfield.fields.I18nTextField(null=True)),
('dependency_values', pretix.base.models.fields.MultiStringField(default=[])),
('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairechild')),
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='pretixbase.questionnaire')),
('user_datafield', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='references', to='pretixbase.question')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.RunPython(
migrate_questions_forward,
migrate_questions_backward,
),
# TODO(questionnaires) remove old columns from Question model
]
@@ -0,0 +1,49 @@
# Generated by Django 5.2.17 on 2026-09-21 11:30
import django.db.models.deletion
from django.db import migrations, models
def fix_unshredded_invoices(apps, _):
Invoice = apps.get_model("pretixbase", "Invoice")
InvoiceLine = apps.get_model("pretixbase", "InvoiceLine")
ignore_fields = (
# bool/int fields are not listed and skipped automatically
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
)
for i in Invoice.objects.filter(shredded=True):
for f in Invoice._meta.fields:
if f.name in ignore_fields:
continue
val = getattr(i, f.name, None)
if val and isinstance(val, str):
setattr(i, f.name, "█")
elif val and isinstance(val, list): # jsonfield
setattr(i, f.name, [])
elif val and isinstance(val, dict): # jsonfield
setattr(i, f.name, {"_shredded": True})
i.save()
InvoiceLine.objects.filter(
attendee_name__isnull=False,
invoice__shredded=True
).update(attendee_name="█")
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.RunPython(
fix_unshredded_invoices,
migrations.RunPython.noop,
),
]
+2 -2
View File
@@ -37,8 +37,8 @@ from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
Questionnaire, QuestionnaireChild, QuestionOption, Quota, SubEventItem,
SubEventItemVariation, itempicture_upload_to,
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
)
from .log import LogEntry
from .mail import OutgoingMail
+1
View File
@@ -166,6 +166,7 @@ class Device(LoggedModel):
)
security_profile = models.CharField(
max_length=190,
verbose_name=_('Security profile'),
default='full',
null=True,
blank=False
+18 -123
View File
@@ -1050,10 +1050,8 @@ class Item(LoggedModel):
replace_year = valid_until.year
replace_month = valid_until.month + self.validity_dynamic_duration_months
while replace_month > 12:
replace_month -= 12
replace_year += 1
replace_year += (replace_month - 1) // 12
replace_month = ((replace_month - 1) % 12) + 1
max_day = calendar.monthrange(replace_year, replace_month)[1]
replace_date = date(
year=replace_year,
@@ -1569,12 +1567,10 @@ class ItemBundle(models.Model):
class Question(LoggedModel):
"""
A question is a data field that can be used to extend an order or a ticket by custom
information, e.g. "Attendee age". To be actually useful, questions need to be added to
one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached
to Orders, OrderPositions or CartPositions.
A question can allow one of several input types, currently:
A question is an input field that can be used to extend a ticket by custom information,
e.g. "Attendee age". The answers are found next to the position. The answers may be found
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
several input types, currently:
* a number (``TYPE_NUMBER``)
* a one-line string (``TYPE_STRING``)
@@ -1594,7 +1590,7 @@ class Question(LoggedModel):
:param required: Whether answering this question is required for submitting an order including
items associated with this question.
:type required: bool
:param items: TO BE REMOVED
:param items: A set of ``Items`` objects that this question should be applied to
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
:type ask_during_checkin: bool
:param show_during_checkin: Whether to show the answer to this question during check-in.
@@ -1653,7 +1649,6 @@ class Question(LoggedModel):
default=ContainerType.ORDERPOSITION,
)
question = I18nTextField(
# TODO(questionnaires) : to be renamed to 'internal_name'
verbose_name=_("Question")
)
identifier = models.CharField(
@@ -1669,7 +1664,6 @@ class Question(LoggedModel):
],
)
help_text = I18nTextField(
# TODO(questionnaires) : to be removed
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
@@ -1679,22 +1673,22 @@ class Question(LoggedModel):
choices=TYPE_CHOICES,
verbose_name=_("Question type")
)
required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
required = models.BooleanField(
default=False,
verbose_name=_("Required question")
)
items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
items = models.ManyToManyField(
Item,
related_name='questions',
verbose_name=_("Products"),
blank=True,
help_text=_('This question will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireChild
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
ask_during_checkin = models.BooleanField(
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
@@ -1704,7 +1698,7 @@ class Question(LoggedModel):
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
)
hidden = models.BooleanField( # to be removed
hidden = models.BooleanField(
verbose_name=_('Hidden question'),
help_text=_('This question will only show up in the backend.'),
default=False
@@ -1713,10 +1707,10 @@ class Question(LoggedModel):
verbose_name=_('Print answer on invoices'),
default=False
)
dependency_question = models.ForeignKey( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
dependency_question = models.ForeignKey(
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[]) # TODO(questionnaires) : to be removed, -> QuestionnaireChild
dependency_values = MultiStringField(default=[])
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
@@ -1755,9 +1749,9 @@ class Question(LoggedModel):
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Data field")
verbose_name_plural = _("Data fields")
ordering = ('question', 'id')
verbose_name = _("Question")
verbose_name_plural = _("Questions")
ordering = ('position', 'id')
unique_together = (('event', 'identifier'),)
def __str__(self):
@@ -1908,7 +1902,7 @@ class Question(LoggedModel):
return answer
@staticmethod
def clean_items(event, items): # TODO(questionnaires) : remove method / move to qc
def clean_items(event, items):
for item in items:
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
@@ -1989,105 +1983,6 @@ class QuestionOption(models.Model):
ordering = ('position', 'id')
class Questionnaire(LoggedModel):
TYPE_ORDER_SALE = "OS"
TYPE_ORDER_POSITION_SALE = "PS"
TYPE_ORDER_POSITION_ATTENDEE_ONLY = "PA"
TYPE_ORDER_POSITION_CHECKIN = "PC"
TYPE_ORDER_POSITION_HIDDEN = "PH"
TYPE_CHOICES = (
(TYPE_ORDER_SALE, _("Order-wide, before purchase")),
(TYPE_ORDER_POSITION_SALE, _("Per product, before purchase")),
(TYPE_ORDER_POSITION_ATTENDEE_ONLY, _("Per product, via attendee link")),
(TYPE_ORDER_POSITION_CHECKIN, _("Per product, at check-in")),
(TYPE_ORDER_POSITION_HIDDEN, _("Per product, hidden")),
)
event = models.ForeignKey(
Event,
related_name="questionnaires",
on_delete=models.CASCADE
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
max_length=255,
)
type = models.CharField(
max_length=5,
choices=TYPE_CHOICES,
verbose_name=_("Questionnaire type")
)
items = models.ManyToManyField(
Item,
related_name='questionnaires',
verbose_name=_("Products"),
blank=True,
help_text=_('This questionnaire will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
all_sales_channels = models.BooleanField(
verbose_name=_("Sell on all sales channels the product is sold on"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'),
blank=True,
)
class QuestionnaireChild(LoggedModel):
SYSTEM_QUESTION_CHOICES = (
('attendee_name_parts', _('Attendee name')),
('attendee_email', _('Attendee email')),
('company', _('Company')),
('street', _('Street')),
('zipcode', _('ZIP code')),
('city', _('City')),
('country', _('Country')),
)
questionnaire = models.ForeignKey(
Questionnaire,
related_name="children",
on_delete=models.CASCADE
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
user_datafield = models.ForeignKey(
Question,
related_name="references",
on_delete=models.CASCADE,
null=True, blank=True,
)
system_datafield = models.CharField(
max_length=25,
choices=SYSTEM_QUESTION_CHOICES,
null=True, blank=True,
)
required = models.BooleanField(
default=False,
verbose_name=_("Required question")
)
label = I18nTextField(
verbose_name=_("Question")
)
help_text = I18nTextField(
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
)
dependency_question = models.ForeignKey(
'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[])
class Quota(LoggedModel):
"""
A quota is a "pool of tickets". It is there to limit the number of items
+32 -65
View File
@@ -1449,12 +1449,6 @@ class QuestionAnswer(models.Model):
else:
return self.answer
def to_dependency_values(self):
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return [o.identifier for o in self.options.all()]
elif self.question.type in (Question.TYPE_BOOLEAN, Question.TYPE_COUNTRYCODE):
return self.answer
def save(self, *args, **kwargs):
if self.orderposition and self.cartposition:
raise ValueError('QuestionAnswer cannot be linked to an order and a cart position at the same time.')
@@ -1599,80 +1593,53 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def cache_answers(self, all=True):
"""
Creates a new property on the object:
questions: a list of Question objects, extended by an 'answer' property
Creates two properties on the object.
(1) answ: a dictionary of question.id → answer string
(2) questions: a list of Question objects, extended by an 'answer' property
"""
self.answ = {}
for a in getattr(self, 'answerlist', self.answers.all()): # use prefetch_related cache from get_cart
self.answ[a.question_id] = a
# We need to clone our question objects, otherwise we will override the cached
# answers of other items in the same cart if the question objects have been
# selected via prefetch_related
if not all:
if hasattr(self.item, 'relevant_questionnaires'):
children = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.childlist)
if hasattr(self.item, 'questions_to_ask'):
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
else:
children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type='PS') for qc in qq.children.all())
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
hidden=False))
else:
children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type__startswith='P') for qc in qq.children.all())
questions = list(copy.copy(q) for q in self.item.questions.all())
qc_cache = {
q.pk: q for q in children
question_cache = {
q.pk: q for q in questions
}
def qc_is_visible(parentid, qvals):
if parentid not in qc_cache:
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
return False
parentqc = qc_cache[parentid]
if parentqc.dependency_question_id and not qc_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False
answer_values = self.get_dependency_answer_values(parentqc)
return any(qval in answer_values for qval in qvals)
if parentid not in self.answ:
return False
return (
('True' in qvals and self.answ[parentid].answer == 'True')
or ('False' in qvals and self.answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals))
)
self.questions = []
for qc in children:
if qc.user_datafield_id and qc.user_datafield_id in self.answer_cache:
qc.answer = self.answer_cache[qc.user_datafield_id]
#qc.answer.question = qc # cache object
elif qc.system_datafield:
qc.answer = self.get_system_answer(qc.system_datafield)
#qc.answer.question = qc # cache object
for q in questions:
if q.id in self.answ:
q.answer = self.answ[q.id]
q.answer.question = q # cache object
else:
qc.answer = ""
if not qc.dependency_question_id or qc_is_visible(qc.dependency_question_id, qc.dependency_values):
self.questions.append(qc)
@cached_property
def answer_cache(self):
return {
aw.question_id: aw for aw in getattr(self, 'answerlist', self.answers.all())
}
def get_dependency_answer_values(self, qc):
if qc.user_datafield_id:
if qc.user_datafield_id not in self.answer_cache:
return None
answer = self.answer_cache[qc.user_datafield_id]
return answer.to_dependency_values()
elif qc.system_datafield:
return [self.get_system_answer(qc.system_datafield)]
else:
raise ValueError('Questionnaire child without datafield has no answer')
def get_system_answer(self, system_datafield_name):
if system_datafield_name == 'attendee_name_parts':
return self.attendee_name_parts
elif system_datafield_name == 'attendee_email':
return self.attendee_email
elif system_datafield_name == 'street':
return self.street
elif system_datafield_name == 'zipcode':
return self.zipcode
elif system_datafield_name == 'city':
return self.city
elif system_datafield_name == 'state':
return self.state
elif system_datafield_name == 'country':
return self.country
else:
raise ValueError('Unknown system question name')
q.answer = ""
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values):
self.questions.append(q)
@property
def net_price(self):
+3 -3
View File
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.presale.views import get_cart_positions
from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__)
@@ -1165,7 +1165,7 @@ class FreeOrderProvider(BasePaymentProvider):
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
from .services.cart import get_fees
cart = get_cart_positions(request)
cart = get_cart(request)
try:
fees = get_fees(event=request.event, request=request,
@@ -1433,7 +1433,7 @@ class GiftCardPayment(BasePaymentProvider):
for p in cs.get('payments', [])
if p.get('info_data', {}).get('gift_card')
]
positions = get_cart_positions(request)
positions = get_cart(request)
testmode = self.event.testmode
else:
used_cards = []
+28 -13
View File
@@ -801,6 +801,18 @@ def generate_compressed_addon_list(op, order, event, only_checked_in=False):
return addonlist
def get_sizebox(page: pypdf.PageObject):
mediabox = page.mediabox
cropbox = page.cropbox
return pypdf.generic.RectangleObject((
max(mediabox[0], cropbox[0]),
max(mediabox[1], cropbox[1]),
min(mediabox[2], cropbox[2]),
min(mediabox[3], cropbox[3]),
))
class Renderer:
def __init__(self, event, layout, background_file):
@@ -1079,7 +1091,7 @@ class Renderer:
fontsize = float(o['fontsize'])
height = float(o['height']) * mm
width = float(o['width']) * mm
while True:
for _i in range(25): # try adapting the font size at most 25 times
p, ad, lineheight = self._text_paragraph(op, order, o, override_fontsize=fontsize)
w, h = p.wrapOn(canvas, width, 1000 * mm)
widths = p.getActualLineWidths0()
@@ -1153,11 +1165,10 @@ class Renderer:
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
page_size = (
self.bg_pdf.pages[0].mediabox[2] - self.bg_pdf.pages[0].mediabox[0],
self.bg_pdf.pages[0].mediabox[3] - self.bg_pdf.pages[0].mediabox[1]
)
if self.bg_pdf.pages[0].get('/Rotate') in (90, 270):
first_page = self.bg_pdf.pages[0]
sizebox = get_sizebox(first_page)
page_size = (sizebox.width, sizebox.height)
if first_page.rotation in (90, 270):
# swap dimensions due to pdf being rotated
page_size = page_size[::-1]
canvas.setPageSize(page_size)
@@ -1312,14 +1323,18 @@ def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
def _merge_with_correct_page_media_box(output: pypdf.PdfWriter, fg_page: pypdf.PageObject, bg_page: pypdf.PageObject):
if bg_page.rotation != 0:
bg_page.transfer_rotation_to_content()
media_box = bg_page.mediabox
"""
Adds fg_page to output, merging bg_page behind it.
If bg_page has a non-zero mergebox/cropbox or is rotated via /Rotate, a transformation is applied to fix this."""
trsf = pypdf.Transformation()
if media_box.bottom != 0:
trsf = trsf.translate(0, -media_box.bottom)
if media_box.left != 0:
trsf = trsf.translate(-media_box.left, 0)
if bg_page.rotation != 0:
trsf = trsf.rotate(-bg_page.rotation)
mb = get_sizebox(bg_page)
pt1 = trsf.apply_on(mb.lower_left)
pt2 = trsf.apply_on(mb.upper_right)
trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1]))
fg_page = output.add_page(fg_page)
fg_page.merge_transformed_page(bg_page, trsf, over=False, expand=False)
+5 -3
View File
@@ -54,7 +54,7 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.mail import EmailMultiAlternatives, SafeMIMEMultipart
from django.core.mail.message import SafeMIMEText
from django.core.mail.message import SafeMIMEText, utf8_charset_qp
from django.db import connection, transaction
from django.db.models import Q
from django.dispatch import receiver
@@ -380,6 +380,8 @@ def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString],
class CustomEmail(EmailMultiAlternatives):
encoding = utf8_charset_qp
def _create_mime_attachment(self, content, mimetype):
"""
Convert the content, mimetype pair into a MIME attachment object.
@@ -449,9 +451,9 @@ def mail_send_task(self, **kwargs) -> bool:
# Rewrite all <img> tags from real URLs or data URLs to inline attachments referred to by content ID
if outgoing_mail.body_html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
html_message = SafeMIMEMultipart(_subtype='related')
html_with_cid, cid_images = replace_images_with_cid_paths(outgoing_mail.body_html)
html_message.attach(SafeMIMEText(html_with_cid, 'html', settings.DEFAULT_CHARSET))
html_message.attach(SafeMIMEText(html_with_cid, 'html', utf8_charset_qp))
attach_cid_images(html_message, cid_images, verify_ssl=True)
email.attach_alternative(html_message, "multipart/related")
File diff suppressed because one or more lines are too long
+22 -10
View File
@@ -50,8 +50,8 @@ from pretix.api.serializers.order import (
from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
CachedCombinedTicket, CachedTicket, Event, Invoice, InvoiceAddress,
OrderPayment, OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders
@@ -598,18 +598,30 @@ class InvoiceShredder(BaseDataShredder):
def shred_data(self, progress_callback=None):
qs_i = self.event.invoices.filter(shredded=False)
total = qs_i.count()
ignore_fields = (
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
)
for i in _progress_helper(qs_i, progress_callback, 0, total):
if i.file:
i.file.delete()
i.shredded = True
i.introductory_text = "█"
i.additional_text = "█"
i.invoice_to = "█"
i.payment_provider_text = "█"
i.transmission_info = {"_shredded": True}
i.save()
i.lines.update(description="█")
i.shredded = True
for f in Invoice._meta.fields:
if f.name in ignore_fields:
continue
val = getattr(i, f.name, None)
if val and isinstance(val, str):
setattr(i, f.name, "█")
elif val and isinstance(val, list): # jsonfield
setattr(i, f.name, [])
elif val and isinstance(val, dict): # jsonfield
setattr(i, f.name, {"_shredded": True})
i.save()
i.lines.update(description="█", attendee_name="█")
class CachedTicketShredder(BaseDataShredder):
+52 -23
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional
from babel import Locale, UnknownLocaleError
from babel.numbers import format_currency
@@ -35,32 +36,32 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Decimal, arg='', hide_currency=False):
if isinstance(value, (float, int)):
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
if isinstance(value, (float, int, str)):
if value == '':
return value
value = Decimal(value)
if value is None:
value = Decimal('0.00')
if not isinstance(value, Decimal):
if value == '':
return value
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
if not arg:
raise ValueError("No currency passed.")
arg = arg.upper()
places = settings.CURRENCY_PLACES.get(arg, 2)
rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
if places < 2 and rounded != value:
# We display decimal places even if we shouldn't for this currency if rounding
# would make the numbers incorrect. If this branch executes, it's likely a bug in
# pretix, but we won't show wrong numbers!
if hide_currency:
return floatformat(value, "2g")
else:
return '{} {}'.format(arg, floatformat(value, "2g"))
if value.normalize().as_tuple().exponent < -9:
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
# land to this filter (even though it should not happen).
value = value.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
currency_places = settings.CURRENCY_PLACES.get(arg, 2)
required_places = -value.normalize().as_tuple().exponent
render_places = max(currency_places, required_places)
if hide_currency:
return floatformat(value, f"{places}g")
return floatformat(value, f"{render_places}g")
try:
locale = Locale(get_babel_locale())
@@ -68,14 +69,29 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
locale = "en"
try:
return format_currency(value, arg, locale=locale)
return format_currency(
value,
arg,
locale=locale,
# We only allow Babel to restrict the digits to the digits defined by the currency if this does not remove any
# precision in case we have sub-currency precision (which we shouldn't have in most places, but it's still
# better than showing wrong data). Note: Weird precision effects can occur after in-database arithmetic
# on SQLite, since SQLite does not have fixed-decimal computation.
currency_digits=currency_places >= required_places,
decimal_quantization=currency_places >= required_places,
)
except:
return '{} {}'.format(arg, floatformat(value, f"{places}g"))
return '{} {}'.format(arg, floatformat(value, f"{render_places}g"))
@register.filter("money_without_currency")
def money_filter_without_currency(value: Optional[Decimal | float | int | str], arg=''):
return money_filter(value, arg, hide_currency=True)
@register.filter("money_numberfield")
def money_numberfield_filter(value: Decimal, arg=''):
if isinstance(value, (float, int)):
def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg=''):
if isinstance(value, (float, int, str)):
value = Decimal(value)
if not isinstance(value, Decimal):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
@@ -87,15 +103,28 @@ def money_numberfield_filter(value: Decimal, arg=''):
@register.filter(is_safe=True)
def tax_rate_format(number):
def tax_rate_format(number: Optional[Decimal | float | int | str]):
"""
Display a Decimal to its significant decimal places, used for tax rates.
"""
assert isinstance(number, Decimal)
if isinstance(number, (float, int, str)):
if number == '':
return number
number = Decimal(number)
if number is None:
number = Decimal('0.00')
if not isinstance(number, Decimal):
raise TypeError("Invalid data type passed to tax rate format filter: %r" % type(number))
if number.normalize().as_tuple().exponent < -9:
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
# land to this filter (even though it should not happen).
number = number.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
return mark_safe(
formats.number_format(
number.normalize(),
-number.as_tuple().exponent,
number,
-number.normalize().as_tuple().exponent,
use_l10n=True,
force_grouping=False,
)
+40 -40
View File
@@ -38,18 +38,19 @@ from pretix.base.middleware import (
register = template.Library()
LOGGER = logging.getLogger(__name__)
_MANIFEST = {}
_MANIFEST_ENTRIES = {}
# TODO more os.path.join ?
MANIFEST_PATH = settings.STATIC_ROOT + "/vite/control/.vite/manifest.json"
MANIFEST_BASE = "vite/control/"
# entry_name -> {"manifest_entry": {...}, "url_base": "..."}
# entry_name -> {"manifest": {...}, "key": "...", "url_base": "..."}
_PLUGIN_REGISTRY = {}
def _discover_plugin_manifests():
"""Discover plugin vite manifests at startup.
Scans installed pretix plugins for a .vite/manifest.json inside a static.dist
Scans installed pretix plugins for a .vite/manifest.json inside a static
directory. Only non-editable (wheel) plugins are expected to ship pre-built
assets; editable plugins are served through the Vite dev server.
"""
@@ -84,10 +85,11 @@ def _discover_plugin_manifests():
url_base = re.search(r'/static/(.+?)/\.vite/', str(manifest_rel)).group(1) + '/'
for _key, entry in plugin_manifest.items():
for key, entry in plugin_manifest.items():
if entry.get('isEntry') and 'name' in entry:
_PLUGIN_REGISTRY[entry['name']] = {
'manifest_entry': entry,
'manifest': plugin_manifest,
'key': key,
'url_base': url_base,
}
except Exception:
@@ -99,6 +101,9 @@ if not settings.VITE_DEV_MODE and not settings.VITE_IGNORE:
try:
with open(MANIFEST_PATH) as fp:
_MANIFEST = json.load(fp)
_MANIFEST_ENTRIES = {
entry["name"]: key for key, entry in _MANIFEST.items() if entry.get("isEntry") and "name" in entry
}
except Exception as e:
LOGGER.warning(f"Error reading vite manifest at {MANIFEST_PATH}: {str(e)}")
@@ -117,34 +122,29 @@ def _generate_script_tag(path, attrs, src=None):
return f'<script {all_attrs} src="{src}"></script>'
def _generate_css_tags(asset, already_processed=None):
"""Recursively builds all CSS tags used in a given asset from the core manifest."""
def _generate_css_tags(manifest, key, url_base, seen_chunks=None, seen_css=None):
"""
Builds the CSS tags for a manifest chunk and everything it imports statically.
Vite lists a chunk's own CSS only. Traverse dependency graph to find all CSS that is imported by this chunk and its dependencies.
"""
if seen_chunks is None:
seen_chunks = set()
if seen_css is None:
seen_css = set()
if key in seen_chunks:
return []
seen_chunks.add(key)
manifest_entry = manifest[key]
tags = []
manifest_entry = _MANIFEST[asset]
if already_processed is None:
already_processed = []
if "css" in manifest_entry:
for css_path in manifest_entry["css"]:
if css_path not in already_processed:
full_path = urljoin(settings.STATIC_URL, MANIFEST_BASE + css_path)
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
already_processed.append(css_path)
if "imports" in manifest_entry:
for import_path in manifest_entry["imports"]:
tags += _generate_css_tags(import_path, already_processed)
return tags
def _generate_plugin_css_tags(manifest_entry, url_base):
"""Build CSS tags for a plugin manifest entry."""
tags = []
if "css" in manifest_entry:
for css_path in manifest_entry["css"]:
full_path = urljoin(settings.STATIC_URL, url_base + css_path)
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
for css_path in manifest_entry.get("css", []):
# Different chunks can reference the same emitted stylesheet.
if css_path not in seen_css:
tags.append(f'<link rel="stylesheet" href="{urljoin(settings.STATIC_URL, url_base + css_path)}" />')
seen_css.add(css_path)
for import_key in manifest_entry.get("imports", []):
tags += _generate_css_tags(manifest, import_key, url_base, seen_chunks, seen_css)
return tags
@@ -161,11 +161,10 @@ def vite_asset(path):
# Check plugin registry (non-editable plugins with pre-built assets)
if path in _PLUGIN_REGISTRY:
info = _PLUGIN_REGISTRY[path]
entry = info['manifest_entry']
url_base = info['url_base']
tags = _generate_plugin_css_tags(entry, url_base)
manifest, key, url_base = info['manifest'], info['key'], info['url_base']
tags = _generate_css_tags(manifest, key, url_base)
# Always use STATIC_URL for pre-built plugin assets, even in dev mode
src = urljoin(settings.STATIC_URL, url_base + entry["file"])
src = urljoin(settings.STATIC_URL, url_base + manifest[key]["file"])
tags.append(_generate_script_tag(path, {"type": "module", "crossorigin": ""}, src=src))
return "".join(tags)
@@ -173,12 +172,13 @@ def vite_asset(path):
if settings.VITE_DEV_MODE:
return _generate_script_tag(path, {"type": "module"})
# Prod mode
manifest_entry = _MANIFEST.get(path)
# Prod mode: core addresses entries by source path (the manifest key), plugins by entry name
key = _MANIFEST_ENTRIES.get(path, path)
manifest_entry = _MANIFEST.get(key)
if not manifest_entry:
raise RuntimeError(f"Cannot find {path} in Vite manifest at {MANIFEST_PATH}")
tags = _generate_css_tags(path)
tags = _generate_css_tags(_MANIFEST, key, MANIFEST_BASE)
tags.append(
_generate_script_tag(
MANIFEST_BASE + manifest_entry["file"], {"type": "module", "crossorigin": ""}
@@ -199,7 +199,7 @@ _dev_importmap_cache = None
def _get_dev_importmap():
"""Fetch the shared-dep import map from the Vite dev server. Cached after first call."""
"""Fetch the shared-dep import map from the Vite dev server. Cached after first successful fetch."""
global _dev_importmap_cache
if _dev_importmap_cache is not None:
return _dev_importmap_cache
@@ -212,7 +212,7 @@ def _get_dev_importmap():
}
except Exception:
LOGGER.warning("Failed to fetch import map from Vite dev server")
_dev_importmap_cache = {}
return {}
return _dev_importmap_cache
+1 -1
View File
@@ -44,7 +44,7 @@ def timeline_for_event(event, subevent=None):
ev = subevent or event
if subevent:
ev_edit_url = reverse(
'control:event.subevent', kwargs={
'control:event.subevent.edit', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'subevent': subevent.pk
+2 -2
View File
@@ -21,8 +21,8 @@
#
import contextvars
from contextlib import contextmanager
from datetime import datetime
from dateutil.parser import parse
from django.utils.timezone import now
from pretix.base.auth import has_event_access_permission
@@ -34,7 +34,7 @@ timemachine_now_var = contextvars.ContextVar('timemachine_now', default=None)
def time_machine_now_assigned_from_request(request):
if hasattr(request, 'event') and f'timemachine_now_dt:{request.event.pk}' in request.session and \
request.event.testmode and has_event_access_permission(request):
request.now_dt = parse(request.session[f'timemachine_now_dt:{request.event.pk}'])
request.now_dt = datetime.fromisoformat(request.session[f'timemachine_now_dt:{request.event.pk}'])
request.now_dt_is_fake = True
else:
request.now_dt = now()
+7 -2
View File
@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.conf import settings
from django.http import (
HttpResponseForbidden, HttpResponseNotFound, HttpResponseServerError,
)
@@ -27,7 +28,6 @@ from django.template import TemplateDoesNotExist, loader
from django.template.loader import get_template
from django.utils.functional import Promise
from django.utils.translation import gettext as _
from sentry_sdk import last_event_id
from pretix.base.i18n import language
from pretix.base.middleware import get_language_from_request
@@ -106,9 +106,14 @@ def server_error(request):
template = loader.get_template('500.html')
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
if settings.SENTRY_ENABLED:
from sentry_sdk import last_event_id
sentry_id = last_event_id()
else:
sentry_id = None
r = HttpResponseServerError(template.render({
'request': request,
'sentry_event_id': last_event_id(),
'sentry_event_id': sentry_id,
}))
r.xframe_options_exempt = True
return r
+15 -25
View File
@@ -27,7 +27,7 @@ from decimal import Decimal
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.db import IntegrityError
from django.db.models import Prefetch, Q, QuerySet
from django.db.models import Prefetch, QuerySet
from django.utils.functional import cached_property
from django.utils.timezone import make_aware
@@ -37,7 +37,7 @@ from pretix.base.forms.questions import (
)
from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionnaireChild, QuestionOption,
QuestionOption,
)
from pretix.base.models.customers import AttendeeProfile
from pretix.base.models.orders import CheckoutSession, Order
@@ -348,37 +348,27 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
@cached_property
def positions(self):
qqs = self.request.event.questionnaires.all()
qqs = self.request.event.questions.all()
if self.only_user_visible:
qqs = qqs.filter(type='PS')
else:
qqs = qqs.filter(type__startswith='P')
qqs = qqs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=self.order.sales_channel.identifier)
)
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
return list(self.order.positions.select_related(
'item', 'variation'
).prefetch_related(
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questionnaires',
Prefetch('item__questions',
qqs.prefetch_related(
Prefetch('children', QuestionnaireChild.objects.prefetch_related(
Prefetch('user_datafield', Question.objects.prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
))
),
to_attr='childlist')
),
to_attr='relevant_questionnaires')
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
).select_related('dependency_question'),
to_attr='questions_to_ask')
))
@cached_property
-2
View File
@@ -26,7 +26,6 @@ from django.core import signing
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.html import format_html
logger = logging.getLogger(__name__)
@@ -61,7 +60,6 @@ def redir_view(request):
u = urllib.parse.urlparse(url)
return render(request, 'pretixbase/redirect.html', {
'hostname': u.hostname,
'bold_hostname': format_html("<strong>{}</strong>", u.hostname),
'url': url,
})
+16
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import logging
import multiprocessing
import os
from celery import Celery, signals
@@ -54,6 +55,21 @@ def on_task_received(sender, request, **kwargs):
logger.info(f"Task {request.id} has trace {trace}")
@receiver(signals.after_setup_task_logger)
def on_after_setup_task_logger(sender, logger, loglevel, logfile, format, colorize, **kwargs):
# This hack seems to be required to get celery to log internal events from eg billiard/pool.py such as
# "worker killed because it used too much memory"
# You can test that it is working by starting a celery worker with a low value like
# --max-memory-per-child 300000
# and then trigger a task. Result should look like this:
# [2026-09-23 10:42:26,234: WARNING/ForkPoolWorker-16]: [???:???] child process exiting after exceeding memory limit (394540KiB / 300000KiB)
# The ???:??? are likely because by copying the handlers, we are also copying the format, but I was unable to find
# a better compatible way.
multi_logger = multiprocessing.get_logger()
multi_logger.setLevel(logging.WARNING)
multi_logger.handlers = logger.handlers
@receiver(signals.task_prerun)
def on_task_prerun(sender, task_id, task, **kwargs):
from pretix.helpers.logs import local
+15 -9
View File
@@ -400,10 +400,10 @@ class EventMetaValueForm(forms.ModelForm):
if self.disabled:
self.fields['value'].widget.attrs['readonly'] = 'readonly'
def clean_slug(self):
def clean_value(self):
if self.disabled:
return self.instance.value if self.instance else None
return self.cleaned_data['slug']
return self.cleaned_data['value']
class Meta:
model = EventMetaValue
@@ -838,9 +838,10 @@ class CancelSettingsForm(SettingsForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.obj.settings.giftcard_expiry_years is not None:
self.fields['cancel_allow_user_paid_refund_as_giftcard'].help_text = gettext(
'You have configured gift cards to be valid {} years plus the year the gift card is issued in.'
).format(self.obj.settings.giftcard_expiry_years)
self.fields['cancel_allow_user_paid_refund_as_giftcard'].help_text = format_html(
gettext('You have configured gift cards to be valid {} years plus the year the gift card is issued in.'),
self.obj.settings.giftcard_expiry_years
)
class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
@@ -1635,10 +1636,15 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_') and k not in self.plain_rendering)
for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
if k.endswith('_attendee'):
if not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
elif 'subject' in k and k.replace("subject", "send") in self.fields:
v.widget.attrs["data-display-dependency"] = f'#id_{k.replace("subject", "send")}'
elif 'text' in k and k.replace("text", "send") in self.fields:
v.widget.attrs["data-display-dependency"] = f'#id_{k.replace("text", "send")}'
class TicketSettingsForm(SettingsForm):
@@ -105,13 +105,6 @@ class GlobalSettingsForm(SettingsForm):
domain=settings.SITE_URL
)
)),
('widget_vue2_origins', forms.CharField(
widget=forms.Textarea(attrs={'rows': '3'}),
required=False,
# Not translated on purpose, this is a temporary feature and contains too many special case words
label="Vue2 widget origins",
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the old vue2-based widget.",
))
])
responses = register_global_settings.send(self)
for r, response in sorted(responses, key=lambda r: str(r[0])):
+2 -2
View File
@@ -22,7 +22,7 @@
from django import forms
from django.core.exceptions import ValidationError
from django.utils.functional import lazy
from django.utils.html import format_html
from django.utils.html import conditional_escape, format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.modelimport_orders import get_order_import_columns
@@ -66,7 +66,7 @@ class ProcessForm(forms.Form):
widget=forms.Select(
attrs={'data-static': 'true'}
),
help_text=c.help_text,
help_text=conditional_escape(c.help_text),
)
def get_columns(self):
+2 -2
View File
@@ -364,7 +364,7 @@ class TeamForm(forms.ModelForm):
for opt in pg.options
],
label=pg.label,
help_text=pg.help_text,
help_text=conditional_escape(pg.help_text),
initial=initial,
widget=forms.RadioSelect,
)
@@ -389,7 +389,7 @@ class TeamForm(forms.ModelForm):
for opt in pg.options
],
label=pg.label,
help_text=pg.help_text,
help_text=conditional_escape(pg.help_text),
initial=initial,
widget=forms.RadioSelect,
)
+2 -2
View File
@@ -435,10 +435,10 @@ class SubEventMetaValueForm(forms.ModelForm):
if self.disabled:
self.fields['value'].widget.attrs['readonly'] = 'readonly'
def clean_slug(self):
def clean_value(self):
if self.disabled:
return self.instance.value if self.instance else None
return self.cleaned_data['slug']
return self.cleaned_data['value']
class Meta:
model = SubEventMetaValue
+2 -1
View File
@@ -44,6 +44,7 @@ from django.db.models import Count, F, Max
from django.db.models.functions import Upper
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
@@ -176,7 +177,7 @@ class VoucherForm(I18nModelForm):
required=False,
widget=forms.TextInput(attrs={'data-seat-guid-field': '1'}),
initial=self.instance.seat.seat_guid if self.instance.seat else '',
help_text=str(self.instance.seat) if self.instance.seat else '',
help_text=escape(str(self.instance.seat) if self.instance.seat else ''),
)
def parse_itemvar(self, data):
+5 -3
View File
@@ -717,6 +717,10 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'),
'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'),
'pretix.property.created': _('An organizer meta property has been created.'),
'pretix.property.deleted': _('An organizer meta property has been deleted.'),
'pretix.property.changed': _('An organizer meta property has been changed.'),
'pretix.property.reordered': _('An organizer meta property has been reordered.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
@@ -774,6 +778,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'),
'pretix.user.settings.2fa.resetdrift': _('Drift and throttle values for two-factor devices have been reset.'),
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
@@ -861,9 +866,6 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
'pretix.event.question.option.changed': _('An answer option has been changed.'),
'pretix.event.questionnaire.added': _('A questionnaire has been created.'),
'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'),
'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'),
'pretix.event.permissions.added': _('A user has been added to the event team.'),
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
+2 -2
View File
@@ -85,8 +85,8 @@ class PermissionMiddleware:
"user.settings.2fa.enable",
"user.settings.2fa.disable",
"user.settings.2fa.regenemergency",
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.webauthn",
"user.settings.2fa.confirm.otp_totp.totpdevice",
"user.settings.2fa.confirm.pretixbase.webauthndevice",
"user.settings.2fa.delete",
"user.settings.2fa.leaveteams",
"auth.logout",
+3 -3
View File
@@ -182,12 +182,12 @@ def get_event_navigation(request: HttpRequest):
'active': 'event.items.categories' in url.url_name,
},
{
'label': _('Questionnaires'),
'url': reverse('control:event.items.questionnaires', kwargs={
'label': _('Questions'),
'url': reverse('control:event.items.questions', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.questionnaires' in url.url_name or 'event.items.questions' in url.url_name,
'active': 'event.items.questions' in url.url_name,
},
{
'label': _('Discounts'),
+3 -11
View File
@@ -133,22 +133,14 @@ This signal is sent out to include custom HTML in the top part of the the event
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
event_dashboard_widgets = EventPluginSignal()
event_dashboard_statistics = EventPluginSignal()
"""
This signal is sent out to include widgets in the event dashboard. Receivers
should return a list of dictionaries, where each dictionary can have the keys:
* content (SafeString, containing HTML)
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
(quarter of a row). May be ignored on small displays, default is "small")
* priority (int, used for ordering, higher comes first, default is 1)
* url (str, optional, if the full widget should be a link)
This signal is sent out to include statistical content on the event dashboard.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
user_dashboard_widgets = GlobalSignal()
@@ -45,8 +45,8 @@
</p>
<div class="form-group buttons">
<input type="submit" class="btn btn-large btn-default" value="Cancel"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="Authorize"/>
<input type="submit" class="btn btn-large btn-default" value="{% trans "Cancel" %}"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="{% trans "Authorize" %}"/>
</div>
</form>
{% else %}
@@ -23,6 +23,7 @@
{% endif %}
{% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-3.6.4.min.js" %}"></script>
<script type="text/javascript" src="{% static "htmx/htmx-2.0.10.min.js" %}"></script>
<script type="text/javascript" src="{% static "js/jquery.formset.js" %}"></script>
<script type="text/javascript" src="{% static "typeahead/typeahead.bundle.js" %}"></script>
<script type="text/javascript" src="{% static "bootstrap/js/bootstrap.js" %}"></script>
@@ -0,0 +1,23 @@
{% load i18n %}
{% load icon %}
{% load humanize %}
{% for cl in lists %}
<a class="quotabox quotabox-full availability"
href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}">
<strong>{{ cl.name }}</strong>
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-{{ cl.percent }}">
</div>
</div>
<div class="numbers">
{% icon "sign-in" %}
{{ cl.checkin_count|default_if_none:0|intcomma }} /
{{ cl.position_count|default_if_none:0|intcomma }}
<br>
{% icon "user" %}
{% blocktrans trimmed with n=cl.inside_count|intcomma %}
{{ n }} present
{% endblocktrans %}
</div>
</a>
{% endfor %}
@@ -0,0 +1,17 @@
{% load i18n %}
{% load icon %}
<div id="comment-form">
<strong>{% trans "Comment" %}:</strong>
{% if "event.settings.general:write" in request.eventpermset %}
<button type="button" class="btn btn-default btn-xs"
hx-get="{% url "control:event.index.comment" organizer=request.organizer.slug event=request.event.slug %}"
hx-target="#comment-form">
{% icon "edit" %}
</button>
{% endif %}
{% if request.event.comment %}
<p>
{{ request.event.comment|linebreaksbr }}
</p>
{% endif %}
</div>
@@ -0,0 +1,16 @@
{% load i18n %}
{% load bootstrap3 %}
<form class="form" method="post"
hx-post="{% url "control:event.index.comment" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
<div>
<p>
{% bootstrap_field form.comment layout="inline" show_help=True show_label=False horizontal_field_class="col-md-12" %}
</p>
<p class="text-right flip">
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
</div>
</form>
@@ -0,0 +1,4 @@
{% load i18n %}
{% for q in quotas %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q full=1 %}
{% endfor %}
@@ -0,0 +1,11 @@
{% load i18n %}
{% load humanize %}
{% if count %}
<a href="{% url "control:event.orders.waitinglist" event=request.event.slug organizer=request.organizer.slug %}">
{% blocktrans trimmed with number=count|intcomma count count=count %}
{{ number }} person waiting
{% plural %}
{{ number }} persons waiting
{% endblocktrans %}
</a>
{% endif %}
@@ -1,4 +1,5 @@
{% load i18n %}
{% load eventsignal %}
{% if has_overpaid_orders %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
@@ -56,3 +57,4 @@
class="btn btn-primary">{% trans "Show sync problems" %}</a>
</div>
{% endif %}
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
@@ -0,0 +1,19 @@
{% load i18n %}
<div class="panel panel-primary">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Welcome to pretix!" %}</h2>
</div>
<div class="panel-body">
<div class="attentionline">{% trans "Get started with our setup tool" %}</div>
<p>
{% blocktrans trimmed %}
To start selling tickets, you need to create products or quotas. The fastest way to create
this is to use our setup tool.
{% endblocktrans %}
</p>
<a href="{% url "control:event.quick" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg">
{% trans "Set up event" %}
</a>
</div>
</div>
@@ -1,12 +0,0 @@
<div class="welcome-wizard">
<h3>{{ title }}</h3>
{% if subtitle %}
<div class="attentionline">{{ subtitle }}</div>
{% endif %}
{% if text %}
<p>{{ text }}</p>
{% endif %}
{% if button_text %}
<p><a href="{{ button_url }}" class="btn btn-primary btn-lg">{{ button_text }}</a></p>
{% endif %}
</div>
@@ -1,10 +1,10 @@
{% load i18n %}
<div class="panel panel-default items">
<div class="panel-heading">
<details class="panel panel-default items" open>
<summary class="panel-heading">
<h3 class="panel-title">
{% trans "Your timeline" %}
{% trans "Timeline" %}
</h3>
</div>
</summary>
<div class="panel-body timeline">
{% regroup timeline by date as tl_list %}
{% for day in tl_list %}
@@ -33,4 +33,4 @@
</div>
{% endfor %}
</div>
</div>
</details>
@@ -3,10 +3,10 @@
{% load eventurl %}
{% load bootstrap3 %}
{% load static %}
{% load eventsignal %}
{% load icon %}
{% block title %}{{ request.event.name }}{% endblock %}
{% block content %}
<h1>
<h1 class="event-dashboard-header">
{{ request.event.name }}
<small>
{% if request.event.has_subevents %}
@@ -14,11 +14,43 @@
{% else %}
{{ request.event.get_date_range_display }}
{% endif %}
<span id="warnings_loading" class="fa fa-cog fa-spin"></span>
<span id="warnings_indicator" class="htmx-indicator">{% icon "cog fa-spin" %}</span>
</small>
<div class="pull-right flip">
<a href="{% url "control:event.live" organizer=request.organizer.slug event=request.event.slug %}"
data-toggle="tooltip"
title="{% trans "Click to change shop status" %}">
{% icon "pencil" %}</a>
<a href="{% url "control:event.live" organizer=request.organizer.slug event=request.event.slug %}"
data-toggle="tooltip"
title="{% trans "Click to change shop status" %}">
{% if request.event.live and request.event.testmode %}
<span class="label label-warning">
<span class="fa fa-warning"></span>
{% trans "public test mode" %}
</span>
{% elif request.event.live %}
<span class="label label-success">
<span class="fa fa-check"></span>
{% trans "live" %}
</span>
{% elif request.event.testmode %}
<span class="label label-warning">
<span class="fa fa-power-off"></span>
{% trans "private test mode" %}
</span>
{% else %}
<span class="label label-danger">
<span class="fa fa-power-off"></span>
{% trans "offline" %}
</span>
{% endif %}
</a>
</div>
<div class="clearfix"></div>
</h1>
<div class="helper-space-below">
{% trans "Shop URL:" %}
<strong>{% trans "Shop URL:" %}</strong>
<span id="shop_url" class="text-muted">{% abseventurl request.event "presale:event.index" %}</span>
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#shop_url">
<i class="fa fa-clipboard" aria-hidden="true"></i>
@@ -31,75 +63,80 @@
{% include "pretixcontrol/event/fragment_qr_dropdown.html" with url=0 %}
</div>
<div class="clearfix"></div>
</div>
<div id="warnings_target"></div>
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
{% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</form>
{% endif %}
{% if not request.event.has_subevents or subevent %}
{% include "pretixcontrol/event/fragment_timeline.html" %}
{% endif %}
<div class="dashboard">
{% for w in widgets %}
<div class="widget-container widget-{{ w.display_size|default:"small" }} {% if w.lazy %}widget-lazy-loading{% endif %}" data-lazy-id="{{ w.lazy }}">
{% if w.url %}{# backwards compatibility #}
<a href="{{ w.url }}" class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{% endif %}
</a>
{% elif w.link %}
<a href="{{ w.link }}" class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x´"></span>
{% else %}
{{ w.content }}
{% endif %}
</a>
{% else %}
<div class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
{% include "pretixcontrol/event/dashboard_partial_comment.html" with url=0 %}
</div>
{% if not request.event.items.exists %}
{% include "pretixcontrol/event/dashboard_partial_welcome.html" %}
{% endif %}
<div hx-get="{% url "control:event.index.warnings" organizer=request.organizer.slug event=request.event.slug %}"
{# loading indicator is somewhere else because the happy case is "no warnings" and shouldn't make the page jump #}
hx-indicator="#warnings_indicator"
hx-trigger="load"></div>
{% if stats %}
{{ stats }}
{% endif %}
<p>&nbsp;</p>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Internal comment" %}
</h3>
</div>
<div class="panel-body">
<form class="form" method="post"
action="{% url "control:event.comment" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
<div class="row">
{% bootstrap_field comment_form.comment layout="horizontal" show_help=True show_label=False horizontal_field_class="col-md-12" %}
{% if not request.event.items.exists %}
{# pass #}
{% elif not request.event.has_subevents %}
{% include "pretixcontrol/event/fragment_timeline.html" %}
<div class="row">
<div class="col-md-6 col-sm-12">
<div class="panel panel-default">
<div class="panel-heading">
<div class="pull-right">
<div hx-get="{% url "control:event.index.waiting" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
<span class="loading-mock loading-mock-text-short"></span>
</div>
</div>
<h2 class="panel-title">{% trans "Quotas" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.quotas" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
</div>
</div>
</div>
{% if not comment_form.readonly %}
<p class="text-right flip">
<br>
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
{% endif %}
</form>
</div>
<div class="col-md-6 col-sm-12">
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Check-in lists" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.checkin" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
{% elif has_checkin_widgets %}
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Check-in lists" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.checkin" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if "event.orders:read" in request.eventpermset or "event.orders:write" in request.eventpermset or "event.settings.general:write" in request.eventpermset or "event.items:write" in request.eventpermset %}
<div class="panel panel-default">
<div class="panel-heading">
@@ -107,10 +144,27 @@
{% trans "Event logs" %}
</h3>
</div>
<ul class="list-group" id="logs_target">
<div class="logs-lazy-loading">
<span class="fa fa-cog fa-4x"></span>
</div>
<ul class="list-group"
hx-get="{% url "control:event.index.logs" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12345" %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="loading-mock loading-mock-text-medium"></span>
</div>
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="loading-mock loading-mock-text-short"></span>
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">
<span class="loading-mock loading-mock-text-medium"></span>
</div>
<div class="col-lg-6 col-sm-12 col-xs-12">
<span class="loading-mock loading-mock-text-long"></span>
</div>
</div>
</li>
{% endfor %}
</ul>
<div class="panel-footer">
<a href="{% url "control:event.log" event=request.event.slug organizer=request.event.organizer.slug %}">
@@ -33,7 +33,7 @@
</div>
<div class="slug-length alert alert-warning helper-display-none-soft">
{% blocktrans trimmed %}
We strongly recommend against using short forms of more then 16 characters.
We strongly recommend against using short forms of more than 16 characters.
{% endblocktrans %}
</div>
</div>
@@ -86,7 +86,7 @@
<a href="?{% url_replace request 'ordering' 'date_to' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Paid tickets per quota" %}
{% trans "Quota utilization" %}
</th>
<th>
{% trans "Status" %}
@@ -151,7 +151,7 @@
</td>
<td>
{% for q in e.first_quotas|slice:":3" %}
{% include "pretixcontrol/fragment_quota_box_paid.html" with quota=q %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q %}
{% endfor %}
{% if e.first_quotas|length > 3 %}
<a href="{% url "control:event.items.quotas" organizer=e.organizer.slug event=e.slug %}"
@@ -1,18 +1,26 @@
{% load i18n %}
<div class="quotabox availability" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}<br>{% blocktrans with date=q.cached_availability_time|date:"SHORT_DATETIME_FORMAT" %}Numbers as of {{ date }}{% endblocktrans %}">
{% load humanize %}
<a class="quotabox {% if full %}quotabox-full{% endif %}" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
{% if full %}
<strong>{{ q.name }}</strong>
{% endif %}
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-100">
</div>
</div>
{% else %}
<div class="progress">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 <= 10 or q.cached_avail.0 >= 100 %}danger{% else %}warning{% endif %} progress-bar-{{ q.inv_percent }}">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-{{ q.percent_paid }}">
</div>
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-unconfirmed progress-bar-{{ q.percent_other }}">
</div>
</div>
{% endif %}
<div class="numbers">
{{ q.cached_avail.1|default_if_none:"∞" }} / {{ q.size|default_if_none:"∞" }}
{{ q.used|default_if_none:"∞" }} / {{ q.size|default_if_none:"∞" }}<br>
{% blocktrans trimmed with n=q.cached_availability_paid_orders %}
{{ n }} paid
{% endblocktrans %}
</div>
</div>
</a>
@@ -0,0 +1,10 @@
<a class="quotabox quotabox-full">
<strong>
<span class="loading-mock loading-mock-text-short"></span>
</strong>
<div class="progress loading-mock"></div>
<div class="numbers">
<span class="loading-mock loading-mock-text-short"></span>
<br><span class="loading-mock loading-mock-text-short"></span>
</div>
</a>
@@ -1,17 +0,0 @@
{% load i18n %}
<a class="quotabox" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">
</div>
{% else %}
<div class="progress">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-{{ q.percent_paid }}">
</div>
</div>
{% endif %}
<div class="numbers">
{{ q.cached_availability_paid_orders|default_if_none:"?" }} / {{ q.size|default_if_none:"∞" }}
</div>
</a>
@@ -12,120 +12,151 @@
{% endblock %}
{% block inside %}
{% if question %}
<h1>{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}</h1>
<h1>{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}</h1>
{% else %}
<h1>{% trans "Data field" %}</h1>
<h1>{% trans "Question" %}</h1>
{% endif %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.type layout="control" %}
<div id="valid-number">
{% bootstrap_field form.valid_number_min layout="control" %}
{% bootstrap_field form.valid_number_max layout="control" %}
</div>
<div id="valid-date">
{% bootstrap_field form.valid_date_min layout="control" %}
{% bootstrap_field form.valid_date_max layout="control" %}
</div>
<div id="valid-datetime">
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_min layout="control" %}
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% blocktrans trimmed with id=form.instance.identifier %}
Answer option {{ id }}
{% endblocktrans %}
</span>
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.type layout="control" %}
{% if form.items %}
{% bootstrap_field form.items layout="control" %}
{% endif %}
{% bootstrap_field form.required layout="control" %}
<div class="alert alert-info alert-required-boolean">
{% blocktrans trimmed %}
If you mark a Yes/No question as required, it means that the user has to select Yes and No is not
accepted. If you want to allow both options, do not make this field required.
{% endblocktrans %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% trans "New answer option" %}
</span>
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
<div id="valid-number">
{% bootstrap_field form.valid_number_min layout="control" %}
{% bootstrap_field form.valid_number_max layout="control" %}
</div>
<div id="valid-date">
{% bootstrap_field form.valid_date_min layout="control" %}
{% bootstrap_field form.valid_date_max layout="control" %}
</div>
<div id="valid-datetime">
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_min layout="control" %}
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% blocktrans trimmed with id=form.instance.identifier %}
Answer option {{ id }}
{% endblocktrans %}
</span>
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
</p>
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% trans "New answer option" %}
</span>
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
</p>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Advanced" %}</legend>
{% bootstrap_field form.help_text layout="control" %}
{% bootstrap_field form.identifier layout="control" %}
{% if form.ask_during_checkin %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% endif %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
{% bootstrap_field form.hidden layout="control" %}
{% if form.print_on_invoice %}
{% bootstrap_field form.print_on_invoice layout="control" %}
{% endif %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_dependency_question">
{% trans "Question dependency" %}
<br><span class="optional">{% trans "Optional" context "form" %}</span>
</label>
<div class="col-md-4">
{% bootstrap_field form.dependency_question layout="inline" form_group_class="inner" %}
</div>
<div class="col-md-5">
<script type="text/plain" id="dependency_value_val">{{ form.instance.dependency_values|escapejson_dumps }}</script>
{% bootstrap_field form.dependency_values layout="inline" form_group_class="inner" %}
</div>
</div>
</fieldset>
</div>
{% bootstrap_field form.identifier layout="control" %}
{% if form.ask_during_checkin %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% endif %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
{% bootstrap_field form.hidden layout="control" %}
{% if form.print_on_invoice %}
{% bootstrap_field form.print_on_invoice layout="control" %}
{% endif %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
@@ -1,41 +0,0 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load static %}
{% load icon %}
{% load compress %}
{% load vite %}
{% block title %}
{% trans "Questionnaires" %}
{% endblock %}
{% block inside %}
<h1>
{% trans "Questionnaires" %}
<a href="{% url "control:event.items.questions" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default pull-right">
{% icon "wrench" %} {% trans "Manage data fields" %}
</a>
</h1>
<p>
{% blocktrans trimmed %}
Questionaires allow your attendees to fill in additional data about their ticket. If you provide food, one
example might be to ask your users about dietary requirements.
{% endblocktrans %}
</p>
<p>
TODO(questionnaires) : add more specific explanation of the questionnaire concept.
</p>
{{ request.event.settings.locales|json_script:"event_locales" }}
{{ questionnaire_type_choices|json_script:"questionnaire_type_choices" }}
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_edit_url %}
{{ datafield_edit_url|json_script:"datafield_edit_url" }}
<div id="questionnaires-editor">
<!-- Vue app mount point -->
</div>
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts" %}
{% endblock %}
@@ -1,8 +1,8 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Data fields" %}{% endblock %}
{% block title %}{% trans "Questions" %}{% endblock %}
{% block inside %}
<h1>{% trans "Data fields" %}</h1>
<h1>{% trans "Questions" %}</h1>
<p>
{% blocktrans trimmed %}
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
@@ -14,19 +14,19 @@
{% if request.event.settings.feature_flag_order_level_questions %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket data field" %}
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket question" %}
</a>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level data field" %}
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level question" %}
</a>
</p>
{% endif %}
<h2>{% trans "Per-ticket data fields" %}</h2>
<p>{% trans "These data field can be used on individual tickets." %}</p>
<h2>{% trans "Per-ticket questions" %}</h2>
<p>{% trans "These questions are asked for every ticket, so possibly multiple times in the same order." %}</p>
{% else %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new data field" %}
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
</a>
</p>
{% endif %}
@@ -35,27 +35,39 @@
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th>{% trans "Products" %}</th>
{% if 'event.items:write' in request.eventpermset %}
<th class="action-col-2"></th>
{% endif %}
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P">
{% for q in questions %}{% if q.container_type == "P" %}
<tr>
<tr data-dnd-id="{{ q.id }}">
<td>
<strong>
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{{ q.question }}
</a>
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{% endif %}
{{ q.question }}
{% if q.pk %}
</a>
{% endif %}
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ q.get_type_display }}
{% if q.pk %}
{{ q.get_type_display }}
{% else %}
{% trans "System question" %}
{% endif %}
</td>
<td>
{% if q.required %}
@@ -72,11 +84,35 @@
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk %}
<ul>
{% for item in q.items.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<small>{% trans "All personalized products" %}</small>
{% endif %}
</td>
{% if 'event.items:write' in request.eventpermset %}
<td class="dnd-container">
</td>
{% endif %}
<td class="text-right flip">
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
{% else %}
{% if 'event.settings.general:write' in request.eventpermset %}
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
{% endif %}
{% endif %}
</td>
</tr>
@@ -87,31 +123,34 @@
{% if request.event.settings.feature_flag_order_level_questions %}
<h2>
{% trans "Per-order data fields" %}
{% trans "Per-order questions" %}
<small><span class="label label-info" title="
{% trans "This functionality is in active development and expected to change significantly over the coming months." %}
{% trans "In pretixPOS, per-order data fields are currently not supported and will not be displayed." %}
{% trans "Per-order questions are currently not supported and will not be displayed in pretixPOS." %}
" data-toggle="tooltip">
<span class="fa fa-flask" aria-hidden="true"></span>
{% trans "Experimental feature" %}
</span></small>
</h2>
<p>{% trans "These data fields are asked once per order." %}</p>
<p>{% trans "These questions are asked once per order." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
{% if 'event.items:write' in request.eventpermset %}
<th class="action-col-2"></th>
{% endif %}
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O">
{% for q in questions %}{% if q.container_type == "O" %}
<tr>
<tr data-dnd-id="{{ q.id }}">
<td>
<strong>
{{ q.question }}
@@ -119,7 +158,11 @@
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ q.get_type_display }}
{% if q.pk %}
{{ q.get_type_display }}
{% else %}
{% trans "System question" %}
{% endif %}
</td>
<td>
{% if q.required %}
@@ -136,6 +179,10 @@
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
{% if 'event.items:write' in request.eventpermset %}
<td class="dnd-container">
</td>
{% endif %}
<td class="text-right flip">
{% if q.pk %}
{% if 'event.items:write' in request.eventpermset %}
@@ -20,10 +20,8 @@
{% endif %}
{% elif question.type == "M" %}
{{ answer.to_string_i18n|rich_text_snippet }}
{% elif question.type %}
{{ answer.to_string_i18n|rich_text_snippet }}
{% else %}
{{ answer|linebreaksbr }}
{{ answer.to_string_i18n|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
@@ -610,9 +610,60 @@
{% endif %}
{% if line.has_questions %}
<dl>
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
<dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name_all_components }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
<dt>{% trans "Attendee email" %}</dt>
<dd>
{% if line.attendee_email %}
{{ line.attendee_email }}
{% if not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
<dt>
{% trans "Attendee company" %}
</dt>
<dd>
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
<dt>
{% trans "Attendee address" %}
</dt>
<dd>
{% if line.street or line.zipcode or line.city or line.country %}
{{ line.street|default_if_none:""|linebreaksbr }}<br>
{{ line.zipcode|default_if_none:"" }} {{ line.city|default_if_none:"" }}<br>
{% if line.state %}{{ line.state_for_address }}<br>{% endif %}
{{ line.country.name|default_if_none:"" }}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% for q in line.questions %}
<dt>
{{ q.label }}
{{ q.question }}
{% if q.ask_during_checkin %}
<span class="fa fa-qrcode text-muted"
data-toggle="tooltip"
@@ -621,49 +672,7 @@
{% endif %}
</dt>
<dd>
{#
{% if q.answer %}
{% if q.answer.file %}
<span class="fa fa-file"></span>
<a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">
{{ q.answer.file_name }}
</a>
<span class="label label-danger" data-toggle="tooltip"
title="{% trans "This file has been uploaded by a user and could contain viruses or other malicious content." %}">
{% trans "UNSAFE" %}
</span>
{% if q.answer.is_image %}
<br>
<a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}" data-lightbox="order"
class="answer-thumb">
<img src="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">
</a>
{% endif %}
{% elif q.type == "M" %}
{{ q.answer.to_string_i18n|rich_text_snippet }}
{% elif q.type %}
{{ q.answer.to_string_i18n|linebreaksbr }}
{% else %}<!-- TODO(questionnaires): proper separation of QuestionAnswer objects and system answers...... -->
{{ q.answer|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
#}
{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}
{% if q.system_datafield == "attendee_email" and not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
</dd>
{% endfor %}
{% for q in line.additional_fields %}
@@ -72,6 +72,7 @@
{% endif %}
</dl>
</fieldset>
{% include "pretixcontrol/event/fragment_timeline.html" %}
<fieldset>
<legend>{% trans "Quotas" %}</legend>
<div class="table-responsive">
@@ -102,7 +102,7 @@
<a href="?{% url_replace request 'filter-ordering' 'date_from' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Paid tickets per quota" %}
{% trans "Quota utilization" %}
</th>
<th>
{% trans "Status" %}
@@ -155,7 +155,7 @@
</td>
<td>
{% for q in s.first_quotas|slice:":3" %}
{% include "pretixcontrol/fragment_quota_box_paid.html" with quota=q %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q %}
{% endfor %}
{% if s.first_quotas|length > 3 %}
<a href="{% url "control:event.items.quotas" organizer=request.event.organizer.slug event=request.event.slug %}?subevent={{ s.id }}"
@@ -15,7 +15,7 @@
<div>
<div class="big-radio radio">
<label>
<input type="radio" required value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}>
<input type="radio" required value="otp_totp.totpdevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "otp_totp.totpdevice" %}checked{% endif %}>
<strong>{% trans "Smartphone with Authenticator app" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
@@ -26,7 +26,7 @@
</div>
<div class="big-radio radio">
<label>
<input type="radio" required value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}>
<input type="radio" required value="pretixbase.webauthndevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "pretixbase.webauthndevice" %}checked{% endif %}>
<strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
@@ -116,14 +116,14 @@
{% for d in devices %}
<li class="list-group-item">
<a class="btn btn-danger btn-xs pull-right flip"
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
href="{% url "control:user.settings.2fa.delete" devicetype=d.model_label device=d.pk %}">
Delete
</a>
{% if d.devicetype == "totp" %}
{% if d.model_label == "otp_totp.totpdevice" %}
<span class="fa fa-mobile"></span>
{% elif d.devicetype == "webauthn" %}
{% elif d.model_label == "pretixbase.webauthndevice" %}
<span class="fa fa-usb"></span>
{% elif d.devicetype == "u2f" %}
{% elif d.model_label == "pretixbase.u2fdevice" %}
<span class="fa fa-usb"></span>
{% endif %}
{{ d.name }}
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "User" %}{% endblock %}
{% block content %}
<h1>{% trans "User" %} {{ user.email }}</h1>
@@ -59,8 +60,83 @@
{% bootstrap_field form.is_verified layout='control' %}
{% endif %}
{% bootstrap_field form.last_login layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
{% bootstrap_field form.needs_password_change layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="panel panel-default">
<div class="panel-heading">
<button class="btn btn-default btn-xs pull-right" type="submit" form="resetdriftthrottle">
{% trans "Reset drift and throttle" %}
</button>
<h3 class="panel-title">
{% trans "Available two-factor authentication methods" %}
</h3>
</div>
<table class="panel-body table table-hover">
{% for d in devices %}
<tr>
<td>
{% if d.model_label == 'otp_totp.totpdevice' %}
TOTP
{% elif d.model_label == 'pretixbase.u2fdevice' %}
U2F
{% elif d.model_label == 'pretixbase.webauthndevice' %}
WebAuthn
{% elif d.model_label == 'otp_static.staticdevice' %}
{% trans "Emergency tokens" %}
{% endif %}
{% if d.confirmed %}
{% icon "check" %}
{% else %}
{% icon "warning" %}
{% endif %}
</td>
<td>
{{ d.name }}
</td>
<td>
{% if d.throttling_failure_timestamp %}
{% blocktrans trimmed with date=d.throttling_failure_timestamp|date:"SHORT_DATETIME_FORMAT" count cnt=d.throttling_failure_count %}
1 failed attempt since {{ date }}
{% plural %}
{{ cnt }} failed attempts since {{ date }}
{% endblocktrans %}
<br>
{% endif %}
{% if d.throttling_enabled and not d.verify_is_allowed.0 %}
<strong>
{% blocktrans trimmed with date=d.verify_is_allowed.1.locked_until|date:"SHORT_DATETIME_FORMAT" %}
Currently locked until {{ date }}
{% endblocktrans %}
</strong>
<br>
{% endif %}
{% if d.model_label == 'otp_totp.totpdevice' %}
<small>
<code>step = {{ d.step }},
t0 = {{ d.t0 }},
digits = {{ d.digits }},
tolerance = {{ d.tolerance }},
drift = {{ d.drift }},
last_t = {{ d.last_t }}</code>
</small>
{% elif d.model_label == 'pretixbase.u2fdevice' %}
<small>
<code>sign_count = {{ d.sign_count }}</code>
</small>
{% elif d.model_label == 'otp_static.staticdevice' %}
<small>
<code>token_count = {{ d.token_set.count }}</code>
</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Team memberships" %}</legend>
@@ -102,4 +178,8 @@
</div>
</div>
</div>
<form action="{% url "control:users.resetdriftthrottle" id=user.pk %}" id="resetdriftthrottle" method="post">
{% csrf_token %}
</form>
{% endblock %}
+7 -6
View File
@@ -78,6 +78,7 @@ urlpatterns = [
re_path(r'^users/(?P<id>\d+)/impersonate$', users.UserImpersonateView.as_view(), name='users.impersonate'),
re_path(r'^users/(?P<id>\d+)/anonymize$', users.UserAnonymizeView.as_view(), name='users.anonymize'),
re_path(r'^users/(?P<id>\d+)/emergencytoken$', users.UserEmergencyTokenView.as_view(), name='users.emergencytoken'),
re_path(r'^users/(?P<id>\d+)/resetdriftthrottle$', users.Reset2FADriftThrottleView.as_view(), name='users.resetdriftthrottle'),
re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
re_path(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
re_path(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
@@ -106,9 +107,9 @@ urlpatterns = [
re_path(r'^settings/2fa/regenemergency', user.User2FARegenerateEmergencyView.as_view(),
name='user.settings.2fa.regenemergency'),
re_path(r'^settings/2fa/totp/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmTOTPView.as_view(),
name='user.settings.2fa.confirm.totp'),
name='user.settings.2fa.confirm.otp_totp.totpdevice'),
re_path(r'^settings/2fa/webauthn/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmWebAuthnView.as_view(),
name='user.settings.2fa.confirm.webauthn'),
name='user.settings.2fa.confirm.pretixbase.webauthndevice'),
re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(),
name='user.settings.2fa.delete'),
re_path(r'^settings/email/confirm$', user.UserEmailConfirmView.as_view(), name='user.settings.email.confirm'),
@@ -272,15 +273,16 @@ urlpatterns = [
re_path(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
re_path(r'^$', dashboards.event_index, name='event.index'),
re_path(r'^qrcode.(?P<filetype>(png|jpeg|gif|svg))$', event.EventQRCode.as_view(), name='event.qrcode'),
re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'),
re_path(r'^dashboard/partials/logs$', dashboards.event_index_log_lazy, name='event.index.logs'),
re_path(r'^dashboard/partials/warnings$', dashboards.event_index_warnings_lazy, name='event.index.warnings'),
re_path(r'^dashboard/partials/quotas$', dashboards.event_index_quotas_lazy, name='event.index.quotas'),
re_path(r'^dashboard/partials/waiting$', dashboards.event_index_waiting_lazy, name='event.index.waiting'),
re_path(r'^dashboard/partials/checkin$', dashboards.event_index_checkin_lazy, name='event.index.checkin'),
re_path(r'^dashboard/partials/comment$', event.EventComment.as_view(), name='event.index.comment'),
re_path(r'^live/$', event.EventLive.as_view(), name='event.live'),
re_path(r'^transfer_session/$', event.EventTransferSession.as_view(), name='event.transfer_session'),
re_path(r'^logs/$', event.EventLog.as_view(), name='event.log'),
re_path(r'^delete/$', event.EventDelete.as_view(), name='event.delete'),
re_path(r'^comment/$', event.EventComment.as_view(),
name='event.comment'),
re_path(r'^quickstart/$', event.QuickSetupView.as_view(), name='event.quick'),
re_path(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'),
re_path(r'^settings/plugins$', event.EventPlugins.as_view(), name='event.settings.plugins'),
@@ -350,7 +352,6 @@ urlpatterns = [
re_path(r'^questions/(?P<question>\d+)/change$', item.QuestionUpdate.as_view(),
name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^questionnaires/$', item.QuestionnairesEditor.as_view(), name='event.items.questionnaires'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
+41 -6
View File
@@ -35,6 +35,7 @@
import base64
import json
import logging
import math
import time
from urllib.parse import quote, urljoin, urlparse
@@ -50,11 +51,12 @@ from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from django_otp import match_token
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from webauthn.helpers import generate_challenge
@@ -463,6 +465,7 @@ class Login2FAView(TemplateView):
token = request.POST.get('token', '').strip().replace(' ', '')
valid = False
retry_after = None
if 'webauthn_challenge' in self.request.session and token.startswith('{'):
challenge = self.request.session['webauthn_challenge']
@@ -518,12 +521,28 @@ class Login2FAView(TemplateView):
valid = True
break
else:
valid = match_token(self.user, token)
if isinstance(valid, StaticDevice):
with transaction.atomic():
for device in devices_for_user(self.user, for_verify=True):
if isinstance(device, StaticDevice) and len(token) < 12:
# If we enter a wrong TOTP token (which is 6 characters), do not even try if it is a valid
# emergency token, which will only "lock up" the StaticDevice due to the throttling plugin
# and just locks people out without security gain.
continue
if device.verify_token(token):
valid = True
break
elif hasattr(device, 'verify_is_allowed'):
verify_allowed, reason_dict = device.verify_is_allowed()
if not verify_allowed:
if not retry_after or reason_dict['locked_until'] > retry_after:
retry_after = reason_dict['locked_until']
else:
device = None
if isinstance(device, StaticDevice):
self.user.send_security_notice([
_("A recovery code for two-factor authentification was used to log in.")
])
if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
pretix_successful_logins.inc(1)
@@ -536,7 +555,23 @@ class Login2FAView(TemplateView):
return redirect('control:index')
else:
pretix_failed_logins.inc(1, reason="2fa")
messages.error(request, _('Invalid code, please try again.'))
msg = _('Invalid code, please try again.')
if retry_after:
seconds = (retry_after - now()).total_seconds()
minutes = seconds / 60
if minutes >= 1:
msg = ngettext(
'Invalid code. Please try again after waiting {value} minute.',
'Invalid code. Please try again after waiting {value} minutes.',
minutes,
).format(value=math.ceil(minutes))
elif seconds >= 1:
msg = ngettext(
'Invalid code. Please try again after waiting {value} second.',
'Invalid code. Please try again after waiting {value} seconds.',
seconds,
).format(value=math.ceil(seconds))
messages.error(request, msg)
return redirect('control:auth.login.2fa')
def get_context_data(self, **kwargs):
+63 -330
View File
@@ -33,317 +33,48 @@
# License for the specific language governing permissions and limitations under the License.
from datetime import timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import intcomma
from django.db.models import (
Count, IntegerField, Max, Min, OuterRef, Prefetch, Q, Subquery, Sum,
Count, IntegerField, Max, Min, OuterRef, Q, Subquery,
)
from django.db.models.functions import Coalesce, Greatest
from django.dispatch import receiver
from django.http import JsonResponse
from django.http import Http404, JsonResponse
from django.shortcuts import render
from django.template.loader import get_template
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import conditional_escape, escape, format_html
from django.utils.html import (
conditional_escape, escape, format_html, format_html_join,
)
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext, pgettext
from pretix.base.decimal import round_decimal
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Order, OrderPosition, OrderRefund,
Question, Quota, SubEvent, Voucher, WaitingListEntry,
Item, ItemCategory, Order, OrderRefund, Question, Quota, Voucher,
WaitingListEntry,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
from pretix.control.signals import (
event_dashboard_widgets, user_dashboard_widgets,
event_dashboard_statistics, user_dashboard_widgets,
)
from pretix.helpers.daterange import daterange
from ...base.models.orders import CancellationRequest
from ...base.models.organizer import TeamQuerySet
from ...base.templatetags.money import money_filter
from ..logdisplay import OVERVIEW_BANLIST
NUM_WIDGET = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>'
from .utils import prepare_quotas_for_boxes
@receiver(signal=event_dashboard_widgets)
def base_widgets(sender, subevent=None, lazy=False, **kwargs):
if not lazy:
prodc = Item.objects.filter(
event=sender, active=True,
).filter(
(Q(available_until__isnull=True) | Q(available_until__gte=now())) &
(Q(available_from__isnull=True) | Q(available_from__lte=now()))
).count()
if subevent:
opqs = OrderPosition.objects.filter(subevent=subevent)
else:
opqs = OrderPosition.objects
tickc = opqs.filter(
order__event=sender, item__admission=True,
order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING),
).count()
paidc = opqs.filter(
order__event=sender, item__admission=True,
order__status=Order.STATUS_PAID,
).count()
if subevent:
rev = opqs.filter(
order__event=sender, order__status=Order.STATUS_PAID
).aggregate(
sum=Sum('price')
)['sum'] or Decimal('0.00')
else:
rev = Order.objects.filter(
event=sender,
status=Order.STATUS_PAID
).aggregate(sum=Sum('total'))['sum'] or Decimal('0.00')
return [
def event_index_waiting_lazy(request, organizer, event):
wles = WaitingListEntry.objects.filter(event=request.event, voucher__isnull=True)
return render(
request,
'pretixcontrol/event/dashboard_partial_waiting.html',
{
'content': None if lazy else format_html(NUM_WIDGET, num=intcomma(tickc), text=_('Attendees (ordered)')),
'lazy': 'attendees-ordered',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(NUM_WIDGET, num=intcomma(paidc), text=_('Attendees (paid)')),
'lazy': 'attendees-paid',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(
NUM_WIDGET,
num=money_filter(round_decimal(rev, sender.currency), sender.currency, hide_currency=True),
text=_('Total revenue ({currency})').format(currency=sender.currency)
),
'lazy': 'total-revenue',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(NUM_WIDGET, num=prodc, text=_('Active products')),
'lazy': 'active-products',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.items', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
]
@receiver(signal=event_dashboard_widgets)
def waitinglist_widgets(sender, subevent=None, lazy=False, **kwargs):
widgets = []
wles = WaitingListEntry.objects.filter(event=sender, subevent=subevent, voucher__isnull=True)
if wles.exists():
if not lazy:
quota_cache = {}
happy = 0
tuples = wles.values('item', 'variation').order_by().annotate(cnt=Count('id'))
items = {
i.pk: i for i in sender.items.filter(id__in=[t['item'] for t in tuples]).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=sender.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)),
)
}
vars = {
i.pk: i for i in ItemVariation.objects.filter(
item__event=sender, id__in=[t['variation'] for t in tuples if t['variation']]
).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=sender.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)),
)
}
for wlt in tuples:
item = items.get(wlt['item'])
variation = vars.get(wlt['variation'])
if not item:
continue
quotas = (
variation._get_quotas(subevent=subevent)
if variation
else item._get_quotas(subevent=subevent)
)
row = (
variation.check_quotas(subevent=subevent, count_waitinglist=False, _cache=quota_cache)
if variation
else item.check_quotas(subevent=subevent, count_waitinglist=False, _cache=quota_cache)
)
if row[1] is None:
happy += wlt['cnt']
elif row[1] > 0:
happy += min(wlt['cnt'], row[1])
for q in quotas:
if q.size is not None:
quota_cache[q.pk] = (quota_cache[q.pk][0], quota_cache[q.pk][1] - min(wlt['cnt'], row[1]))
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET, num=intcomma(happy), text=_('available to give to people on waiting list')
),
'lazy': 'waitinglist-avail',
'priority': 50,
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
})
})
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET, num=intcomma(wles.count()), text=_('total waiting list length')
),
'lazy': 'waitinglist-length',
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def quota_widgets(sender, subevent=None, lazy=False, **kwargs):
widgets = []
quotas = sender.quotas.filter(subevent=subevent)
qa = QuotaAvailability()
if quotas:
qa.queue(*quotas)
qa.compute(allow_cache=True)
for q in quotas:
if not lazy:
status, left = qa.results[q] if q in qa.results else q.availability(allow_cache=True)
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET,
num='{}/{}'.format(intcomma(left), intcomma(q.size)) if q.size is not None else '\u221e',
text=format_html(_('{quota} left'), quota=q.name)
),
'lazy': 'quota-{}'.format(q.pk),
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.items.quotas.show', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
'quota': q.id
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def shop_state_widget(sender, **kwargs):
return [{
'display_size': 'small',
'priority': 1000,
'content': format_html(
'<div class="shopstate">{t1}<br><span class="{cls}"><span class="fa {icon}"></span> {state}</span>{t2}</div>',
t1=_('Your ticket shop is'), t2=_('Click here to change'),
state=_('live') if sender.live and not sender.testmode else (
_('live and in test mode') if sender.live else (
_('not yet public') if not sender.testmode else (
_('in private test mode')
)
)
),
icon='fa-check-circle' if sender.live and not sender.testmode else (
'fa-warning' if sender.live else (
'fa-times-circle' if not sender.testmode else (
'fa-times-circle'
)
)
),
cls='live' if sender.live else 'off'
),
'url': reverse('control:event.live', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
}]
@receiver(signal=event_dashboard_widgets)
def checkin_widget(sender, subevent=None, lazy=False, **kwargs):
widgets = []
qs = sender.checkin_lists.filter(subevent=subevent)
for cl in qs:
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET,
num='{}/{}'.format(intcomma(cl.inside_count), intcomma(cl.position_count)),
text=format_html(_('Present – {list}'), list=cl.name)
),
'lazy': 'checkin-{}'.format(cl.pk),
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.orders.checkinlists.show', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
'list': cl.pk
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def welcome_wizard_widget(sender, **kwargs):
template = get_template('pretixcontrol/event/dashboard_widget_welcome.html')
ctx = {
'title': _('Welcome to pretix!')
}
kwargs = {'event': sender.slug, 'organizer': sender.organizer.slug}
if not sender.items.exists():
ctx.update({
'subtitle': _('Get started with our setup tool'),
'text': _('To start selling tickets, you need to create products or quotas. The fastest way to create '
'this is to use our setup tool.'),
'button_text': _('Set up event'),
'button_url': reverse('control:event.quick', kwargs=kwargs)
})
else:
return []
return [{
'display_size': 'full',
'priority': 2000,
'content': template.render(ctx)
}]
'count': wles.count,
}
)
def build_json_response(widgets):
@@ -353,62 +84,38 @@ def build_json_response(widgets):
def event_index(request, organizer, event):
from pretix.control.forms.event import CommentForm
can_view_orders = request.user.has_event_permission(
request.organizer,
request.event,
'event.orders:read',
request=request
)
subevent = None
if request.GET.get("subevent", "") != "" and request.event.has_subevents:
i = request.GET.get("subevent", "")
try:
subevent = request.event.subevents.get(pk=i)
except SubEvent.DoesNotExist:
pass
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
request=request)
can_change_event_settings = request.user.has_event_permission(request.organizer, request.event,
'event.settings.general:write', request=request)
widgets = []
stats = []
if can_view_orders:
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=True):
widgets.extend(result)
for r, result in event_dashboard_statistics.send(sender=request.event, request=request):
stats.append(result)
ctx = {
'widgets': rearrange(widgets),
'subevent': subevent,
'comment_form': CommentForm(initial={'comment': request.event.comment}, readonly=not can_change_event_settings),
'stats': format_html_join("", "{}", [(s,) for s in stats]),
}
ctx['timeline'] = [
{
'date': t.datetime.astimezone(request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(request.event.timezone)
}
for t in timeline_for_event(request.event, subevent)
]
if not request.event.has_subevents:
ctx['timeline'] = [
{
'date': t.datetime.astimezone(request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(request.event.timezone)
}
for t in timeline_for_event(request.event, None)
]
ctx['today'] = now().astimezone(request.event.timezone).date()
ctx['nearly_now'] = now().astimezone(request.event.timezone) - timedelta(seconds=20)
ctx['has_checkin_widgets'] = not request.event.has_subevents or request.event.checkin_lists.filter(subevent=None).exists()
resp = render(request, 'pretixcontrol/event/index.html', ctx)
return resp
def event_index_widgets_lazy(request, organizer, event):
subevent = None
if request.GET.get("subevent", "") != "" and request.event.has_subevents:
i = request.GET.get("subevent", "")
try:
subevent = request.event.subevents.get(pk=i)
except SubEvent.DoesNotExist:
pass
widgets = []
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=False):
widgets.extend(result)
return build_json_response(widgets)
def event_index_warnings_lazy(request, organizer, event):
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
request=request)
@@ -445,6 +152,32 @@ def event_index_warnings_lazy(request, organizer, event):
)
def event_index_quotas_lazy(request, organizer, event):
if request.event.has_subevents:
raise Http404()
quotas = request.event.quotas.filter(subevent=None)[:10]
prepare_quotas_for_boxes(quotas)
return render(
request,
'pretixcontrol/event/dashboard_partial_quotas.html',
{
'quotas': quotas,
}
)
def event_index_checkin_lazy(request, organizer, event):
lists = request.event.checkin_lists.filter(subevent=None)[:10]
return render(
request,
'pretixcontrol/event/dashboard_partial_checkin.html',
{
'lists': lists,
}
)
def event_index_log_lazy(request, organizer, event):
qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application',
'device').order_by('-datetime')
+16 -18
View File
@@ -57,10 +57,9 @@ from django.db import transaction
from django.db.models import Count, ProtectedError
from django.forms import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
JsonResponse,
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.shortcuts import redirect
from django.shortcuts import redirect, render
from django.urls import NoReverseMatch, reverse
from django.utils.functional import cached_property
from django.utils.html import conditional_escape, format_html
@@ -1299,24 +1298,23 @@ class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
return LogFilterForm(data=self.request.GET, organizer=self.request.organizer)
class EventComment(EventPermissionRequiredMixin, View):
class EventComment(EventPermissionRequiredMixin, UpdateView):
permission = 'event.settings.general:write'
form_class = CommentForm
template_name = 'pretixcontrol/event/dashboard_partial_comment_form.html'
def post(self, *args, **kwargs):
form = CommentForm(self.request.POST)
if form.is_valid():
self.request.event.comment = form.cleaned_data.get('comment')
self.request.event.save()
self.request.event.log_action('pretix.event.comment', user=self.request.user, data={
'new_comment': form.cleaned_data.get('comment')
})
messages.success(self.request, _('The comment has been updated.'))
else:
messages.error(self.request, _('Could not update the comment.'))
return redirect(self.get_success_url())
def get_object(self, queryset=None):
return self.request.event
def get(self, *args, **kwargs):
return HttpResponseNotAllowed(['POST'])
def form_valid(self, form):
form.save()
self.request.event.log_action('pretix.event.comment', user=self.request.user, data={
'new_comment': form.cleaned_data.get('comment')
})
return render(
self.request,
'pretixcontrol/event/dashboard_partial_comment.html',
)
def get_success_url(self) -> str:
return reverse('control:event.index', kwargs={
+62 -7
View File
@@ -56,7 +56,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import FormView, ListView, TemplateView, View
from django.views.generic import FormView, ListView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
@@ -65,6 +65,7 @@ from pretix.api.serializers.item import (
ItemVariationSerializer,
)
from pretix.base.forms import I18nFormSet
from pretix.base.forms.questions import get_fake_attendee_questions
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
@@ -436,7 +437,66 @@ class QuestionList(ListView):
template_name = 'pretixcontrol/items/questions.html'
def get_queryset(self):
return self.request.event.questions
return self.request.event.questions.prefetch_related('items')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
questions = get_fake_attendee_questions(self.request.event.settings)
questions += list(ctx['questions'])
questions.sort(key=lambda q: q.position)
ctx['questions'] = questions
return ctx
@transaction.atomic
@event_permission_required("event.items:write")
@require_http_methods(["POST"])
def reorder_questions(request, organizer, event):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
qs = request.event.questions.filter(container_type=request.GET['container_type'])
# filter system_questions - normal questions are int/digit, system_questions strings
custom_question_ids = [i for i in ids if i.isdigit()]
input_questions = list(qs.filter(id__in=custom_question_ids))
if len(input_questions) != len(custom_question_ids):
raise Http404(_("Some of the provided object ids are invalid."))
if len(input_questions) != qs.count():
raise Http404(_("Not all objects have been selected."))
for q in input_questions:
pos = ids.index(str(q.pk))
if pos != q.position: # Save unneccessary UPDATE queries
q.position = pos
q.save(update_fields=['position'])
q.log_action(
'pretix.event.question.reordered', user=request.user, data={
'position': pos,
}
)
if request.GET['container_type'] == Question.ContainerType.ORDERPOSITION:
system_question_order = {}
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
if s in ids:
system_question_order[s] = ids.index(s)
else:
system_question_order[s] = -1
request.event.settings.system_question_order = system_question_order
request.event.log_action(
'pretix.event.settings', user=request.user, data={
'system_question_order': system_question_order,
}
)
return HttpResponse()
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
@@ -706,11 +766,6 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/items/questionnaires.html'
class QuotaQueryMixin:
@cached_property
+2 -14
View File
@@ -52,13 +52,13 @@ from pretix.base.forms import SafeSessionWizardView
from pretix.base.i18n import language
from pretix.base.models import Event, EventMetaValue, Organizer, Quota, Team
from pretix.base.models.organizer import TeamQuerySet
from pretix.base.services.quotas import QuotaAvailability
from pretix.control.forms.event import (
EventWizardBasicsForm, EventWizardCopyForm, EventWizardFoundationForm,
)
from pretix.control.forms.filter import EventFilterForm
from pretix.control.permissions import OrganizerPermissionRequiredMixin
from pretix.control.views import PaginationMixin
from pretix.control.views.utils import prepare_quotas_for_boxes
class EventList(PaginationMixin, ListView):
@@ -117,19 +117,7 @@ class EventList(PaginationMixin, ListView):
s.first_quotas = s.first_quotas[:4]
quotas += list(s.first_quotas)
qa = QuotaAvailability(early_out=False)
for q in quotas:
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
if q.size is not None:
q.percent_paid = min(
100,
round(q.cached_availability_paid_orders / q.size * 100) if q.size > 0 else 100
)
prepare_quotas_for_boxes(quotas)
return ctx
@cached_property
+20 -4
View File
@@ -1177,6 +1177,8 @@ class OrderRefundView(OrderView):
manual_value = formats.sanitize_separators(manual_value)
try:
manual_value = Decimal(manual_value)
if manual_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1206,6 +1208,8 @@ class OrderRefundView(OrderView):
giftcard_value = formats.sanitize_separators(giftcard_value)
try:
giftcard_value = Decimal(giftcard_value)
if giftcard_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1255,6 +1259,8 @@ class OrderRefundView(OrderView):
offsetting_value = formats.sanitize_separators(offsetting_value)
try:
offsetting_value = Decimal(offsetting_value)
if offsetting_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1271,6 +1277,9 @@ class OrderRefundView(OrderView):
if offset_order.event.currency != self.request.event.currency:
messages.error(self.request, _('You entered an order in an event with a different currency.'))
is_valid = False
if not self.request.user.has_event_permission(self.request.organizer, offset_order.event, 'event.orders:write', request=self.request):
messages.error(self.request, _('You entered an order in an event that you do not have access to.'))
is_valid = False
refunds.append(OrderRefund(
order=order,
payment=None,
@@ -1286,10 +1295,13 @@ class OrderRefundView(OrderView):
))
for identifier, prov in self.request.event.get_payment_providers().items():
# prof = process form, not a typo for prov(ider)
prof_value = self.request.POST.get(f'newrefund-{identifier}', '0') or '0'
prof_value = formats.sanitize_separators(prof_value)
try:
prof_value = Decimal(prof_value)
if prof_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1313,6 +1325,8 @@ class OrderRefundView(OrderView):
value = formats.sanitize_separators(value)
try:
value = Decimal(value)
if value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1342,7 +1356,12 @@ class OrderRefundView(OrderView):
))
any_success = False
if refund_selected == full_refund and is_valid:
if refund_selected != full_refund:
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
'amount.'))
is_valid = False
if is_valid:
for r in refunds:
r.save()
order.log_action('pretix.event.order.refund.created', {
@@ -1414,9 +1433,6 @@ class OrderRefundView(OrderView):
)
}))
return redirect(self.get_order_url())
else:
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
'amount.'))
def post(self, *args, **kwargs):
if self.start_form.is_valid():
+4 -4
View File
@@ -778,9 +778,9 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["events"] = self.request.user.get_events_with_permission(
"event.settings.general:write", request=self.request
).filter(organizer=self.request.organizer)
# Assumption: Who has access to modify organizer settings may see all events and disable/enable plugins
# for them. Otherwise, inconsistent situations occur.
kwargs["events"] = self.request.organizer.events.all()
kwargs["initial"] = {
"events": self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)')
}
@@ -2202,7 +2202,7 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
owner=self.request.user,
timezone=str(get_current_timezone()),
)
if not self.scheduled:
if not self.scheduled and not self.scheduled_copy_from:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
"mail_template": gettext(
+19 -13
View File
@@ -69,6 +69,7 @@ from pretix.base.models.orders import CancellationRequest
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.services import tickets
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
from pretix.base.views.tasks import AsyncFormView
from pretix.control.forms.checkin import SimpleCheckinListForm
from pretix.control.forms.filter import SubEventFilterForm
@@ -83,6 +84,7 @@ from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import subevent_forms
from pretix.control.views import PaginationMixin
from pretix.control.views.event import MetaDataEditorMixin
from pretix.control.views.utils import prepare_quotas_for_boxes
from pretix.helpers import GroupConcat
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.i18n import get_format_without_seconds
@@ -144,19 +146,7 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
s.first_quotas = s.first_quotas[:4]
quotas += list(s.first_quotas)
qa = QuotaAvailability(early_out=False)
for q in quotas:
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
if q.size is not None:
q.percent_paid = min(
100,
round(q.cached_availability_paid_orders / q.size * 100) if q.size > 0 else 100
)
prepare_quotas_for_boxes(quotas)
return ctx
@@ -566,6 +556,17 @@ class SubEventDetail(EventPermissionRequiredMixin, DetailView):
for quota in ctx["quotas"]:
quota.cached_avail = qa.results[quota]
ctx['timeline'] = [
{
'date': t.datetime.astimezone(self.request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(self.request.event.timezone)
}
for t in timeline_for_event(self.request.event, self.object)
]
ctx['today'] = now().astimezone(self.request.event.timezone).date()
ctx['nearly_now'] = now().astimezone(self.request.event.timezone) - timedelta(seconds=20)
return super().get_context_data(
**kwargs,
**ctx,
@@ -1276,6 +1277,11 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie
self._default_meta = self.request.event.meta_data
for p in self.request.organizer.meta_properties.all():
if p.protected and not self.request.user.has_organizer_permission(
self.request.organizer, 'organizer.settings.general:write', request=self.request
):
continue
inst = SubEventMetaValue(property=p)
if len(matches[p.id]) == 1 and matches[p.id][0]['c'] == total:
inst.value = matches[p.id][0]['value']
+15 -25
View File
@@ -59,6 +59,7 @@ from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_scopes import scopes_disabled
@@ -85,7 +86,6 @@ from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import session_reauth
from pretix.helpers.u2f import websafe_encode
REAL_DEVICE_TYPES = (TOTPDevice, WebAuthnDevice, U2FDevice)
logger = logging.getLogger(__name__)
@@ -313,17 +313,7 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
except StaticDevice.DoesNotExist:
ctx['static_tokens_device'] = None
ctx['devices'] = []
for dt in REAL_DEVICE_TYPES:
objs = list(dt.objects.filter(user=self.request.user, confirmed=True))
for obj in objs:
if dt == TOTPDevice:
obj.devicetype = 'totp'
elif dt == U2FDevice:
obj.devicetype = 'u2f'
elif dt == WebAuthnDevice:
obj.devicetype = 'webauthn'
ctx['devices'] += objs
ctx['devices'] = [d for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)]
ctx['obligatory'] = None
if settings.PRETIX_OBLIGATORY_2FA is True:
@@ -342,9 +332,9 @@ class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView):
template_name = 'pretixcontrol/user/2fa_add.html'
def form_valid(self, form):
if form.cleaned_data['devicetype'] == 'totp':
if form.cleaned_data['devicetype'] == 'otp_totp.totpdevice':
dev = TOTPDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name'])
elif form.cleaned_data['devicetype'] == 'webauthn':
elif form.cleaned_data['devicetype'] == 'pretixbase.webauthndevice':
if not self.request.is_secure():
messages.error(self.request,
_('Security devices are only available if pretix is served via HTTPS.'))
@@ -364,11 +354,11 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
@cached_property
def device(self):
if self.kwargs['devicetype'] == 'totp':
if self.kwargs['devicetype'] == 'otp_totp.totpdevice':
return get_object_or_404(TOTPDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'webauthn':
elif self.kwargs['devicetype'] == 'pretixbase.webauthndevice':
return get_object_or_404(WebAuthnDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'u2f':
elif self.kwargs['devicetype'] == 'pretixbase.u2fdevice':
return get_object_or_404(U2FDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
def get_context_data(self, **kwargs):
@@ -386,7 +376,7 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
msgs = [
_('A two-factor authentication device has been removed from your account.')
]
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
self.request.user.require_2fa = False
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.disabled', user=self.request.user)
@@ -461,7 +451,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
).first()
if credential_id_exists:
messages.error(request, _('This security device is already registered.'))
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.pretixbase.webauthndevice', kwargs={
'device': self.device.pk
}))
@@ -475,7 +465,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
self.device.save()
self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={
'id': self.device.pk,
'devicetype': 'u2f',
'devicetype': 'pretixbase.webauthndevice',
'name': self.device.name,
})
notices = [
@@ -503,7 +493,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
except Exception:
messages.error(request, _('The registration could not be completed. Please try again.'))
logger.exception('WebAuthn registration failed')
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.pretixbase.webauthndevice', kwargs={
'device': self.device.pk
}))
@@ -537,7 +527,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={
'id': self.device.pk,
'name': self.device.name,
'devicetype': 'totp'
'devicetype': 'otp_totp.totpdevice'
})
notices = [
_('A new two-factor authentication device has been added to your account.')
@@ -563,7 +553,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
else:
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.'))
return redirect(reverse('control:user.settings.2fa.confirm.totp', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.otp_totp.totpdevice', kwargs={
'device': self.device.pk
}))
@@ -594,7 +584,7 @@ class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html'
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(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
messages.error(request, _('Please configure at least one device before enabling two-factor '
'authentication.'))
return redirect(reverse('control:user.settings.2fa'))
@@ -956,7 +946,7 @@ class UserEmailConfirmView(FormView):
@transaction.atomic()
def form_valid(self, form):
reason = self.request.GET['reason']
reason = self.request.GET.get('reason')
if reason not in ('email_change', 'email_verify'):
raise PermissionDenied
try:
+23
View File
@@ -40,6 +40,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import ListView, TemplateView
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from hijack import signals
@@ -107,6 +108,9 @@ class UserEditView(AdministratorPermissionRequiredMixin, RecentAuthenticationReq
ctx['backend'] = (
b[self.object.auth_backend].verbose_name if self.object.auth_backend in b else self.object.auth_backend
)
ctx['devices'] = devices_for_user(self.object)
return ctx
def get_success_url(self):
@@ -183,6 +187,25 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
return reverse('control:users.edit', kwargs=self.kwargs)
class Reset2FADriftThrottleView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
def get(self, request, *args, **kwargs):
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
self.object.totpdevice_set.update(drift=0, throttling_failure_timestamp=None, throttling_failure_count=0)
self.object.staticdevice_set.update(throttling_failure_timestamp=None, throttling_failure_count=0)
self.object.log_action('pretix.user.settings.2fa.resetdrift', user=self.request.user)
messages.success(request, _(
'The drift values for TOTP devices have been reset.'
))
return redirect(self.get_success_url())
def get_success_url(self):
return reverse('control:users.edit', kwargs=self.kwargs)
class UserAnonymizeView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView):
template_name = "pretixcontrol/users/anonymize.html"

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