Compare commits

..

105 Commits

Author SHA1 Message Date
Mira Weller 7e5d535fc4 Fix special case if addon product is now set to require a voucher (vouchers for addons are not supported) 2026-07-07 18:36:54 +02:00
Mira Weller b65cdaa44a Add test case for:
Order has product with addon 1
Addon 1 is hidden if Addon 2 is available
When order is changed, Addon 1 should not be removable
2026-07-07 17:50:22 +02:00
Mira Weller 527cbe6eab Fix indentation 2026-07-07 17:39:13 +02:00
Mira Weller 3ac683412a Remove duplicate checks
Originally, these checks were the same as the checks in OrderChangeManager, but now that OrderChangeManager respects pre-existing but unavailable addons, they are not accurate any more.
2026-07-07 17:39:13 +02:00
Mira Weller 76e1428d57 Plugin compat 2026-07-07 17:18:09 +02:00
Mira Weller 5ffb1f469e Merge branch 'master' into pajowu/rebook_dependency2
# Conflicts:
#	src/pretix/presale/views/event.py
2026-07-07 17:14:53 +02:00
Mira Weller 5f086b0970 Use current_unavailability_reason attribute instead of unavailability_reason() 2026-07-07 17:08:37 +02:00
Raphael Michel dc1b62fc56 Vouchers: Allow to bulk-update (#6096)
* Vouchers: Allow to bulk-delete larger numbers

* [DRAFT] voucher bulk update

* untested draft

* Minor fixes

* some tests and fixes

* More tests

* Bulk vouchers - refactor (#6353)

* Reuse parse_itemvar from VoucherForm

* Refactor: Method for _bulk update check

* remove unused parameter

* Refactor: Deduplicate get_affected_quotas code

* Remove unused delete button

* Prevent accidental update of *all* vouchers (cf PR #4960)

* Make sure we actually edit the selected items

* Use dataclass for bulk-edit data, deduplicate quota-blocking logic

* Move dataclass to models.py

* Fix linter errors

* Fix tests

* Fix tests

---------

Co-authored-by: luelista <weller@rami.io>
2026-07-07 17:03:25 +02:00
Mira Weller 7f1b371ef5 Helper for checking sales channels 2026-07-07 16:58:40 +02:00
Mira Weller b22c91be6d Rename get_grouped_items to prepare_item_list_for_shop 2026-07-07 16:44:37 +02:00
Mira Weller 4ffb1a6935 Fix comment 2026-07-07 16:40:43 +02:00
Mira Weller 92a9a78283 Change imports to get_grouped_items' new location 2026-07-07 16:39:36 +02:00
Mira Weller 626b5ed8e6 Make Item[Variation].unavailability_reason helper functions next to get_grouped_items 2026-07-07 16:37:30 +02:00
Mira Weller 0ae47c7f58 Move get_grouped_items into its own file 2026-07-07 16:36:12 +02:00
Raphael Michel ea2c81e6dc Improve handling of logout during asynchronous tasks (Z#23238978) (#6341)
* Handle logout during async task requests properly

* Handle fragments safely on webcheckin logout

* Remove unnecessary comment

* Revert hash change
2026-07-07 16:14:00 +02:00
Raphael Michel 2bd5e90a86 Fix isort 2026-07-07 15:43:00 +02:00
Raphael Michel 8095134400 Add query tagging for periodic tasks 2026-07-07 14:55:32 +02:00
dependabot[bot] 62f2ed55b2 Update webauthn requirement from ==2.8.* to ==3.0.* (#6350)
Updates the requirements on [webauthn](https://github.com/duo-labs/py_webauthn) to permit the latest version.
- [Release notes](https://github.com/duo-labs/py_webauthn/releases)
- [Changelog](https://github.com/duo-labs/py_webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/duo-labs/py_webauthn/compare/v2.8.0-alpha1...v3.0.0)

---
updated-dependencies:
- dependency-name: webauthn
  dependency-version: 3.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 12:13:40 +02:00
Raphael Michel 3533450601 Add trace IDs from request to celery task (#6333)
* Add trace IDs from request to celery task

Celery tasks will log the request ID they were triggered from:

    [2026-07-02 10:33:17,614: INFO/MainProcess] Task pretix.base.services.orders.cancel_order[5f3104b3-0a54-4e49-921e-c866c4dc4c6d] received
    [2026-07-02 10:33:17,614: INFO/MainProcess] Task 5f3104b3-0a54-4e49-921e-c866c4dc4c6d has trace 4d389638-00ab-4c4d-bdec-d73ac322bf44

Nested celery tasks will then contain both the request ID as well as the previous tasks:

    [2026-07-02 10:33:18,354: INFO/MainProcess] Task pretix.base.services.notifications.notify[d52a3a49-9c89-4f67-bdde-9f773586fc07] received
    [2026-07-02 10:33:18,354: INFO/MainProcess] Task d52a3a49-9c89-4f67-bdde-9f773586fc07 has trace 4d389638-00ab-4c4d-bdec-d73ac322bf44 5f3104b3-0a54-4e49-921e-c866c4dc4c6d

* Apply suggestion from @luelista

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

---------

Co-authored-by: luelista <weller@rami.io>
2026-07-07 12:09:27 +02:00
pajowu 5fb827c8f5 CheckInListPDF export: remove double html escaping with PlainTextParagraph (Z#23239571) (#6347) 2026-07-07 12:00:17 +02:00
Raphael Michel af6727bbe2 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6362 of 6362 strings)

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

powered by weblate
2026-07-07 11:57:51 +02:00
Raphael Michel f0d2733e8d Translations: Update German
Currently translated at 100.0% (6362 of 6362 strings)

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

powered by weblate
2026-07-07 11:57:51 +02:00
Raphael Michel 90bca935de Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-07-07 11:34:15 +02:00
Raphael Michel 9c752b7263 Translations: Fix typo 2026-07-07 11:29:03 +02:00
CVZ-es bfdd61a14c Translations: Update French
Currently translated at 99.9% (6355 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
CVZ-es 6aca806239 Translations: Update Spanish
Currently translated at 100.0% (6360 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
CVZ-es 8512a8c4a5 Translations: Update French
Currently translated at 99.9% (6354 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
Raphael Michel ecee6f6c30 Translations: Update German (informal) (de_Informal)
Currently translated at 99.9% (6359 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
Raphael Michel 846ec77c50 Translations: Update German
Currently translated at 99.9% (6359 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
CVZ-es 49c69c816c Translations: Update French
Currently translated at 99.8% (6348 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
Hijiri Umemoto f3868576c5 Translations: Update Japanese
Currently translated at 100.0% (260 of 260 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
Hijiri Umemoto 3857361559 Translations: Update Japanese
Currently translated at 100.0% (6360 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
CVZ-es b0c50de331 Translations: Update Spanish
Currently translated at 100.0% (6360 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
CVZ-es 1dad6bf801 Translations: Update French
Currently translated at 99.7% (6347 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
Raphael Michel d94d4bd57e Translations: Update German (informal) (de_Informal)
Currently translated at 99.9% (6358 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
CVZ-es 2437f768c9 Translations: Update German (informal) (de_Informal)
Currently translated at 99.9% (6358 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
Raphael Michel ab621890a2 Translations: Update German
Currently translated at 99.9% (6359 of 6360 strings)

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

powered by weblate
2026-07-07 11:28:33 +02:00
Raphael Michel a9135919a1 Translations: Update wordlists 2026-07-07 11:21:59 +02:00
Raphael Michel e2c437fd43 Update confusing verbiage after #6216 2026-07-07 10:58:27 +02:00
Raphael Michel ba10fc8041 Event product list: Fix another performance issue for large series (#6331)
This is a follow-up for #6318, basically the same problem but in a
neighboring query.

Performance comparison for real-world event with 13k subevents:

In [15]: %time get_grouped_items(e, subevent=se, channel=e.organizer.sales_channels.get(identifier="web"))
CPU times: user 49.1 ms, sys: 3.79 ms, total: 52.9 ms
Wall time: 1.12 s
Out[15]: ([<Item: xxx>], True

In [16]: %time get_grouped_items_patched(e, subevent=se, channel=e.organizer.sales_channels.get(identifier="web"))
CPU times: user 30.2 ms, sys: 445 μs, total: 30.6 ms
Wall time: 45.3 ms
Out[16]: ([<Item: xxx>], True)
2026-07-07 10:48:50 +02:00
Raphael Michel 7732794317 Do not use redis cache at import time (#6321)
During our [2026-06-27 incident](https://pretix.eu/about/en/blog/20260630-pretix-hosted-outage/),
we noticed that pretix is using redis at import time. This means that
gunicorn and celery process were unable to start on servers who could
currently not reach redis. This is kinda mitigated through auto-restart
on systemd or docker level, but that's not really how it is supposed to
work. Celery even has smart retry/reconnect logic that becomes pointless
this way.
2026-07-07 10:47:12 +02:00
Raphael Michel 8a47ba7ff5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-07-06 17:53:13 +02:00
sweenu dd2a74557d Add contact URL setting (#6132)
* Add contact URL setting

* Apply suggestions from code review

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

* Apply suggestion from @raphaelm

---------

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-07-06 17:49:55 +02:00
Richard Schreiber bb9c4fa18d Event calendar: Improve iOS VoiceOver month dropdown selection (Z#23234442) (#6320) 2026-07-06 17:38:23 +02:00
dependabot[bot] ac356acd29 Update django-redis requirement from ==6.0.* to ==7.0.* (#6244)
Updates the requirements on [django-redis](https://github.com/jazzband/django-redis) to permit the latest version.
- [Release notes](https://github.com/jazzband/django-redis/releases)
- [Changelog](https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jazzband/django-redis/compare/6.0.0...7.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-07-06 17:35:52 +02:00
Kian Cross 54eadaffcc Improve admin-facing email templates (#6216)
* Improve subject lines for admin-facing emails

A few of the current subjects are ambiguous about the expected
action, and some omit context that would help in an inbox preview
(which event, which address). The rewrites bring them closer to
common conventions in modern transactional email (verb-led,
recipient-addressed, with recipient-meaningful variables). Two
themes:

- Action-required emails lead with the action verb. "Reset your
  password", "Confirm event cancellation and bulk refund", and
  "Confirm <address> as a sender address" tell the recipient up
  front what's expected, where "Password recovery", "Bulk-refund
  confirmation" and "Sender address verification" did not.

- Surface the relevant variable when the email is about something
  specific. "Data shredding completed for <event>" is more useful
  than the generic version when an admin manages several events.
  "You've been invited to join <organizer>" names the inviting
  organizer. "Confirm <address> as a sender address" names the
  address.

The remaining rewrites are lighter rewordings. "New sign-in to
your account" replaces "Login from new source detected" because
"source" is jargon a non-technical recipient wouldn't recognise.
"Changes to your account" replaces "Account information changed"
because the possessive frames the email as being about the
recipient's own account.

Also fixes a hardcoded "pretix" in the confirmation-code subject.

* Standardise admin email sign-offs as "Thanks, The <instance> Team"

The current sign-offs ("Best regards, Your <instance> team") have
a formal tone. A review of the last ~20 transactional emails in
my inbox showed most senders use something friendlier:

- Thanks: Deliveroo, Starling Bank, GitHub, Cloudflare
- Thank you: AWS
- Sincerely: Google Workspace

A small minority (e.g., Sentry) had no sign-off at all. "Thanks"
was the most common, and among that group "The <instance> Team"
was the consistent phrasing rather than "Your <instance> team".

Two templates (cancel_confirm, export_failed) didn't have a
sign-off; they now get one for consistency. Notification emails
are deliberately excluded: they're system alerts rather than
direct correspondence.

* Add anti-phishing notice to admin emails containing confirmation codes

Three admin emails send the recipient a confirmation code to
enter back into a form: confirmation_code, email_setup, and
cancel_confirm. Only confirmation_code had an anti-phishing
warning, and its wording was awkward ("Please do never give this
code to another person. Our support team will never ask for this
code.").

This commit standardises the warning across all three:

> Don't share this code with anyone. The <instance> team will
> never ask you for it.

* Add structured details to login-notice email

The single-sentence body ("The login was performed using <agent>
on <os> from <country>.") is replaced with a labelled bullet list:
Time, Browser, Operating system, Device, Country.

Time and Device are new fields. Device is omitted when ua-parser
can't identify the device, Country when GeoIP isn't available,
so the user only sees fields with real values.

* Restructure notification.txt for clearer layout

- Attributes: bullet list instead of paragraph-per-attribute.

- Actions: label gets a colon, URL on its own paragraph (was
  4-space-indented code block).

- Footer: separated by --- and bulleted (manage / disable
  links). "Click here X" phrasing dropped (incidentally moots
  a missing-"to" typo).

- Minor whitespace fix: detail-block endif now matches the
  placement of the rest of the template.

notification.html's footer text is also updated, only to match
the new .txt wording (link labels and intro line). No
structural changes to the HTML template.

* Improve confirmation-code email reason strings

- Drop the redundant "to confirm" opener.

- Replace hardcoded "your pretix account" in email_verify
  with "{instance}".

* Polish admin email body copy

A small wording and formatting pass on the admin email bodies,
in three loosely-grouped themes:

1. Sentence case for body text (previously lowercase after
   "Hello,"), matching standard English convention.

2. Light restructuring where helpful: bullet lists for sets
   of labelled facts; 4-space-indented code blocks for codes
   the recipient is meant to type back.

3. Phrasing polish. Some sentences tightened or shortened.
   Largely matters of taste, but generally read smoother.

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-07-06 17:25:52 +02:00
Swiftb0y 617a548b19 Add event placeholder support to more fields (#6098)
* Add event placeholder support to more fields

I found it useful to be able to use the `{event}` placeholder in
some fields such as the "End of presale text" because I don't need
to fix the texts creating a new event by cloning another event.
I made placeholders available to the other fields as well (where not too
difficult). Specifically `presale_has_ended_text`, `voucher_explanation_text`
`banner_text`, `banner_text_bottom` and `event_info_text`.
In addition, I grouped them under a new `texts` variable in the `context`
(including `frontpage_text` which was part of the root `context` previously).

* change compute location

---------

Co-authored-by: Raphael Michel <michel@rami.io>
2026-07-06 17:18:04 +02:00
dependabot[bot] fe0f7864e7 Update reportlab requirement from ==4.5.* to ==5.0.* (#6307)
Updates the requirements on [reportlab](https://www.reportlab.com/) to permit the latest version.

---
updated-dependencies:
- dependency-name: reportlab
  dependency-version: 5.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 17:03:12 +02:00
Kian Cross fc4a2f5508 Customer SSO: Improve one-time token error message (#5841)
* Improve SSO one-time token error message

Replace the generic 'invalid one-time token' message shown after failed
SSO login attempts with a clearer, user-facing explanation of what went
wrong and how to recover.

* Remove unneeded classes from headings

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

* Apply suggestions from code review

Added `trimmed` to translation blocks

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

* Reword SSO error template for improved translation clarity

---------

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-07-06 16:59:59 +02:00
dependabot[bot] 4cb32a753f Update requests requirement from ==2.32.* to ==2.34.* (#6178)
Updates the requirements on [requests](https://github.com/psf/requests) to permit the latest version.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.0...v2.34.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.34.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>
Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-07-06 16:57:27 +02:00
KarlKeu00 3ae9aabcfb Add Docker secrets support in config (#6250)
* Add Docker secrets support in config

* ruff format

* Remove gracefully fallback exception handling

* Add support for loading secret fallbacks from environment file
2026-07-06 15:09:31 +02:00
Kara Engelhardt 5c895bed81 Fix crash in rebooking if addon has hidden_if_item_available 2026-07-06 13:31:14 +02:00
Puneet Dixit 3270c4e583 Bank transfer: Fix incorrect HTML escaping in QR Code (fix #4780) (#6201)
* Fix EPC QR beneficiary escaping

* Fix EPC QR script encoding

Keep EPC QR helper output as a plain string and serialize payment QR payloads as JSON script data before the QR replacement JavaScript parses them. This preserves apostrophes without relying on mark_safe in the helper.

Assisted-by: OpenAI GPT-5 <noreply@openai.com>

* "type safety"

---------

Co-authored-by: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-07-06 12:52:29 +02:00
sweenu 3b285a89dd Sentry: Optionally enable sending logs (#6222)
* Enable sending logs to Sentry

* change to info

---------

Co-authored-by: Raphael Michel <michel@rami.io>
2026-07-06 12:38:28 +02:00
sweenu c1683df1fd Allow to update -> Allow updating (#6131) 2026-07-06 11:54:33 +02:00
Kian Cross 6fdcbcebd2 Add typeahead suggestions for voucher tag field (#6058)
Suggest existing tags as the user types in the voucher tag field.
Waiting list voucher tags are excluded from suggestions.
2026-07-06 11:42:05 +02:00
Kian Cross ddbea7c32e Customer login during checkout: Display SSO login errors inline (#5840)
Replace the JavaScript alert used for SSO popup login errors during the
checkout flow with an inline HTML error message.
2026-07-06 11:38:43 +02:00
luelista 3f8ed0f722 Only set vat_id_validated if vat_id non-empty (#6210) 2026-07-06 11:21:53 +02:00
sweenu 388eb7de96 BasePaymentProvider: Fix type hinting on execute_payment() (#6078) 2026-07-06 11:19:53 +02:00
dependabot[bot] df4c83d849 Update pillow requirement from ==12.2.* to ==12.3.*
Updates the requirements on [pillow](https://github.com/python-pillow/Pillow) to permit the latest version.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 11:12:11 +02:00
Benedikt Bormann fa4cec8a2d Translations: Update German
Currently translated at 100.0% (260 of 260 strings)

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

powered by weblate
2026-07-06 11:11:51 +02:00
Lukas Bockstaller 943b319557 use cookieretry only on presale event pages (Z#23236752) (#6297)
* use cookieretry only on presale event pages

* use csrfcookieretry only on event index page

* include static tag

* include csrfcookieretry in order.html as well

* Update src/pretix/static/pretixpresale/js/csrfcookieretry.js

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-07-03 13:56:47 +02:00
Richard Schreiber 28b13667ce Widget: add beta-flag to URL (#6338) 2026-07-03 12:04:54 +02:00
Raphael Michel b00d1c9156 Bump django-querytagger 2026-07-03 11:53:34 +02:00
Raphael Michel 120317a8f2 Fix linter issue 2026-07-03 11:00:48 +02:00
Raphael Michel 7d5b00a610 Fix linter issues 2026-07-03 10:54:44 +02:00
Raphael Michel 83612c7d65 Merge branch 'security/harden-staffsession' into 'master'
Harden StaffSession handling

See merge request pretix/pretix!42
2026-07-03 10:41:39 +02:00
Mira Weller 7a5f96369a Harden StaffSession handling 2026-07-03 10:41:39 +02:00
Raphael Michel 7fd6bf41f9 Merge branch 'csp-refactor' into 'master'
CSP refactor

See merge request pretix/pretix!35
2026-07-03 10:33:35 +02:00
Mira Weller 458c3d4b83 CSP refactor 2026-07-03 10:33:35 +02:00
Raphael Michel d10d061e45 Merge branch 'check-csp' into 'master'
Check CSP components before rendering, prevent format string traversal

See merge request pretix/pretix!33
2026-07-03 10:26:23 +02:00
Mira Weller d30bca50f7 Check CSP components before rendering, prevent format string traversal 2026-07-03 10:26:23 +02:00
Phin Wolkwitz 3903aca7c9 Add Thai translations to community languages (Z#23239401) (#6334) 2026-07-02 17:44:28 +02:00
Raphael Michel 493c920aba Install django-querytagger (#6332)
* Install django-querytagger

* Update pyproject.toml
2026-07-02 14:40:02 +02:00
Raphael Michel 09b7bc00b0 Organizer calendar: Respect event_calendar_future_only (Z#23238776) (#6326)
We initially didn't do this for two reasons:

- Performance implications of calling the settings store for every event
  that shows up in the calendar. As of d43e85da, we need that anyways.
- Performance implications of filtering in Python except SQL but... it
  can't really be worse than not filtering at all.
- We don't easily know if it's valid for all events so we can't stop
  rendering the unused calendar rows. That's an acceptable issue for
  now, still better than nothing. We can always optimize later.

So we might as well implement it.
2026-07-02 14:31:44 +02:00
dependabot[bot] 2e195c0274 Update sentry-sdk requirement from ==2.63.* to ==2.64.*
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.63.0...2.64.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 13:53:33 +02:00
Raphael Michel 18cb9c1816 Drop line numbers from gettext .po files (#6330)
Knowing what file a string comes from is useful, but the line number is less
useful and changes a lot, causing very unreadable diffs of translation
files. I propose we drop them and only include the file names
2026-07-02 10:21:09 +02:00
Richard Schreiber c3e0120f9f Improve calendar explorability for VoiceOver on iOS 2026-07-02 08:13:09 +02:00
Raphael Michel 67f7fec134 Fix flake8 issue 2026-07-01 18:01:50 +02:00
Raphael Michel c2c97f31ca Bump version to 2026.7.0.dev0 2026-07-01 16:33:10 +02:00
Raphael Michel fd565ecdb2 Bump version to 2026.6.0 2026-07-01 16:33:07 +02:00
Nikita Mitasov f35b13b686 Translations: Update Russian
Currently translated at 18.9% (1200 of 6343 strings)

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

powered by weblate
2026-07-01 16:32:06 +02:00
CVZ-es 550bb675f5 Translations: Update Spanish
Currently translated at 100.0% (260 of 260 strings)

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

powered by weblate
2026-07-01 16:32:06 +02:00
CVZ-es adc9c9d514 Translations: Update Spanish
Currently translated at 100.0% (6343 of 6343 strings)

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

powered by weblate
2026-07-01 16:32:06 +02:00
CVZ-es 8441c4bc7a Translations: Update French
Currently translated at 100.0% (260 of 260 strings)

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

powered by weblate
2026-07-01 16:32:06 +02:00
CVZ-es 97ff252c09 Translations: Update French
Currently translated at 100.0% (6343 of 6343 strings)

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

powered by weblate
2026-07-01 16:32:06 +02:00
CVZ-es d3ca2ac1e5 Translations: Update German
Currently translated at 100.0% (6343 of 6343 strings)

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

powered by weblate
2026-07-01 16:32:06 +02:00
Raphael Michel 6eebaaa563 [SECURITY] Hardening for user impersonation feature (CVE-2026-13602)
---------

Co-authored-by: Mira Weller <weller@pretix.eu>
2026-07-01 15:15:43 +02:00
Raphael Michel c9781f012b [SECURITY] Centralize framebreaking logic from payment plugins to core (CVE-2026-13602)
- Add central framebreaker page via safelink helper
- Update paypal, paypal2 and stripe plugins to use central framebreaker
- Add CSP header to cookies.html

---------

Co-authored-by: Mira Weller <weller@pretix.eu>
2026-07-01 15:15:43 +02:00
Mira Weller 000bf54105 [SECURITY] Allowlisting and changed salts for safelink and safelink_callback (CVE-2026-13602)
---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-07-01 15:15:43 +02:00
Lukas Bockstaller e42d3d632f filter out the 404 log records from django.request (#6324) 2026-07-01 11:33:04 +02:00
Lukas Bockstaller 3bf5a5e478 include settings attribute during type checking (#6323)
* include settings attribute during type checking

* isort
2026-07-01 11:32:54 +02:00
Raphael Michel a6f31df0d4 Do not assign domain across organizers when copying events 2026-06-30 18:51:49 +02:00
Raphael Michel d40492748a Event product list: Hotfix for pathological performance in large event series (#6318)
* Event product list: Hotfix for pathological performance

* Stop outputting bullsht numbers to widget
2026-06-29 19:02:06 +02:00
Richard Schreiber f4ca230af7 Improve voucher import unique code checks (#6311)
* Check for duplicate codes in import

* Check for existing codes instead of failing on db-level

* as we do not lock, catch IntegrityErrors due to race-conditions on import

* fix flake8
2026-06-29 14:37:41 +02:00
Richard Schreiber 4fb1748bf6 Fix handling country=None in attendee profiles (#6309)
* Fix handling country=None in AttendeeProfile describe

* Update checkoutflow.py

* fix more occurences

* handle country=None in InvoiceAddress even if it is not allowed
2026-06-29 14:32:01 +02:00
Raphael Michel 9d668af102 Discover translatable strings in .ts files (Z#23238475) 2026-06-28 17:51:40 +02:00
Raphael Michel 80fd4a3b2a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6343 of 6343 strings)

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

powered by weblate
2026-06-28 17:48:28 +02:00
Raphael Michel 65bb2283d4 Translations: Update German
Currently translated at 100.0% (6343 of 6343 strings)

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

powered by weblate
2026-06-28 17:48:28 +02:00
Raphael Michel 00751e8911 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6343 of 6343 strings)

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

powered by weblate
2026-06-28 17:48:28 +02:00
Raphael Michel 957a066475 Translations: Update German
Currently translated at 100.0% (6343 of 6343 strings)

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

powered by weblate
2026-06-28 17:48:28 +02:00
Raphael Michel 7e077bdd7e Subevent detail: Fix incorrect ticket count (Z#23238481) 2026-06-28 17:43:22 +02:00
Raphael Michel e80d84ec3c Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-06-28 16:44:09 +02:00
Kim Lozano dc3b742d8c Translations: Update Catalan
Currently translated at 29.7% (1875 of 6302 strings)

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

powered by weblate
2026-06-28 16:42:32 +02:00
Raphael Michel 1f5fe1b237 SSRF protection: Edge case handling for CGNAT and v4/v6 mapping (Z#23236468) (#6260)
* SSRF protection: Edge case handling for CGNAT and v4/v6 mapping (Z#23236468)

* SMTP SSRF protection: Edge case handling for CGNAT and v4/v6 mapping (#6264)

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-06-26 16:45:20 +02:00
233 changed files with 523897 additions and 557777 deletions
+7 -6
View File
@@ -53,7 +53,8 @@ dependencies = [
"django-oauth-toolkit==2.3.*",
"django-otp==1.7.*",
"django-phonenumber-field==8.4.*",
"django-redis==6.0.*",
"django-querytagger==0.0.3",
"django-redis==7.0.*",
"django-scopes==2.0.*",
"django-statici18n==2.7.*",
"djangorestframework==3.17.*",
@@ -76,7 +77,7 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.13.*",
"phonenumberslite==9.0.*",
"Pillow==12.2.*",
"Pillow==12.3.*",
"pretix-plugin-build",
"protobuf==7.35.*",
"psycopg2-binary",
@@ -91,9 +92,9 @@ dependencies = [
"pyuca",
"qrcode==8.2",
"redis==7.4.*",
"reportlab==4.5.*",
"requests==2.32.*",
"sentry-sdk==2.63.*",
"reportlab==5.0.*",
"requests==2.34.*",
"sentry-sdk==2.64.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -101,7 +102,7 @@ dependencies = [
"tqdm==4.*",
"ua-parser==1.0.*",
"vobject==0.9.*",
"webauthn==2.8.*",
"webauthn==3.0.*",
"zeep==4.3.*"
]
+2 -2
View File
@@ -6,8 +6,8 @@ localecompile:
./manage.py compilemessages
localegen:
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
./manage.py makemessages --keep-pot --add-location file --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot --add-location file -e js,ts,vue -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
staticfiles: npminstall npmbuild jsi18n
./manage.py collectstatic --noinput
+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.6.0.dev0"
__version__ = "2026.7.0.dev0"
+1
View File
@@ -118,6 +118,7 @@ ALL_LANGUAGES = [
('sv', _('Swedish')),
('es', _('Spanish')),
('es-419', _('Spanish (Latin America)')),
('th', _('Thai')),
('tr', _('Turkish')),
('uk', _('Ukrainian')),
]
+1
View File
@@ -747,6 +747,7 @@ class EventSettingsSerializer(SettingsSerializer):
'max_items_per_order',
'reservation_time',
'contact_mail',
'contact_url',
'show_variations_expanded',
'hide_sold_out',
'meta_noindex',
+5 -2
View File
@@ -27,7 +27,7 @@ from django.core.exceptions import ObjectDoesNotExist
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_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -492,7 +492,9 @@ class TeamInviteSerializer(serializers.ModelSerializer):
def _send_invite(self, instance):
mail(
instance.email,
_('Account invitation'),
gettext('You\'ve been invited to join %(organizer)s') % {
'organizer': self.context['organizer'].name,
},
'pretixcontrol/email/invitation.txt',
{
'instance': settings.PRETIX_INSTANCE_NAME,
@@ -574,6 +576,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'contact_url',
'imprint_url',
'organizer_info_text',
'event_list_type',
+5
View File
@@ -57,6 +57,8 @@ logger = logging.getLogger('pretix.base.email')
T = TypeVar("T", bound=EmailBackend)
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
try:
@@ -253,12 +255,15 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
if ip_addr.is_multicast:
raise socket.error(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise socket.error(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise socket.error(f"Request to private address {sa[0]} blocked")
if check_ip4 in _cgnat_net:
raise socket.error(f"Request to RFC 6598 address {sa[0]} blocked")
sock = None
try:
+16 -10
View File
@@ -53,6 +53,7 @@ from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
from django.utils.functional import lazy
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.text import format_lazy
@@ -324,16 +325,21 @@ class WrappedPhonePrefixSelect(Select):
initial = None
def __init__(self, initial=None):
choices = [("", "---------")]
def _get_choices():
choices = [("", "---------")]
if initial:
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
continue
if initial in values:
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
return choices
choices = lazy(_get_choices, list)()
if initial:
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
continue
if initial in values:
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
super().__init__(choices=choices, attrs={
'aria-label': pgettext_lazy('phonenumber', 'International area code'),
'autocomplete': 'tel-country-code',
@@ -1398,7 +1404,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
elif self.validate_vat_id and vat_id_applicable:
try:
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
self.instance.vat_id_validated = True
self.instance.vat_id_validated = bool(normalized_id)
self.instance.vat_id = data['vat_id'] = normalized_id
except VATIDFinalError as e:
if self.all_optional:
@@ -0,0 +1,29 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Do nothing. Useful for startup performance testing."
def handle(self, *args, **options):
pass
@@ -40,6 +40,7 @@ from django.core.cache import cache
from django.core.management.base import BaseCommand
from django.db import close_old_connections
from django.dispatch.dispatcher import NO_RECEIVERS
from django_querytagger.tagging import with_tag
from pretix.helpers.periodic import SKIPPED
@@ -82,7 +83,8 @@ class Command(BaseCommand):
try:
# Check if the DB connection is still good, it might be closed if the previous task took too long.
close_old_connections()
r = receiver(signal=periodic_task, sender=self)
with with_tag(f"periodictask={name}"):
r = receiver(signal=periodic_task, sender=self)
except Exception as err:
if isinstance(err, KeyboardInterrupt):
raise err
+78 -56
View File
@@ -19,6 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
import re
from collections import OrderedDict
from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -43,6 +45,8 @@ from pretix.multidomain.urlreverse import (
)
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
_supported = None
@@ -223,7 +227,26 @@ def _parse_csp(header):
return h
VALID_CSP_DIRECTIVES = [
"child-src", "connect-src", "default-src", "fenced-frame-src", "font-src", "form-action", "frame-src", "img-src",
"manifest-src", "media-src", "object-src", "prefetch-src", "report-uri", "script-src", "script-src-elem",
"script-src-attr", "style-src", "style-src-elem", "style-src-attr", "worker-src",
]
CSP_ILLEGAL_CHARS = re.compile(r'[\s,;]')
def _sanitize_csp(h):
for k, v in h.items():
if k not in VALID_CSP_DIRECTIVES:
raise ValueError("Invalid CSP directive " + k)
if any(CSP_ILLEGAL_CHARS.search(el) for el in v):
logger.warning("Stripping invalid component from CSP: %r", h)
h[k] = [el for el in v if not CSP_ILLEGAL_CHARS.search(el)]
def _render_csp(h):
_sanitize_csp(h)
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
@@ -243,21 +266,7 @@ def _merge_csp(a, b):
class SecurityMiddleware(MiddlewareMixin):
CSP_EXEMPT = (
'/api/v1/docs/',
)
def process_response(self, request, resp):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
url = resolve(request.path_info)
if settings.DEBUG and resp.status_code >= 400:
# Don't use CSP on debug error page as it breaks of Django's fancy error
# pages
@@ -268,18 +277,15 @@ class SecurityMiddleware(MiddlewareMixin):
# https://github.com/pretix/pretix/issues/765
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
img_src = []
gs = global_settings_object(request)
if gs.settings.leaflet_tiles:
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
if not getattr(resp, '_csp_ignore', False):
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp))
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
font_src = set()
if hasattr(request, 'event'):
for font in get_fonts(request.event, pdf_support_required=False).values():
for path in list(nested_dict_values(font)):
font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
return resp
def _build_csp(self, request, resp):
url = resolve(request.path_info)
h = {
'default-src': ["{static}"],
@@ -288,8 +294,8 @@ class SecurityMiddleware(MiddlewareMixin):
'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"],
'connect-src': ["{dynamic}", "{media}"],
'img-src': ["{static}", "{media}", "data:"] + img_src,
'font-src': ["{static}"] + list(font_src),
'img-src': ["{static}", "{media}", "data:"],
'font-src': ["{static}"],
'media-src': ["{static}", "data:"],
# form-action is not only used to match on form actions, but also on URLs
# form-actions redirect to. In the context of e.g. payment providers or
@@ -298,6 +304,13 @@ class SecurityMiddleware(MiddlewareMixin):
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
}
gs = global_settings_object(request)
if gs.settings.leaflet_tiles:
h['img-src'].append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
if hasattr(request, 'event'):
h['font-src'] += list(self._get_font_origins(request.event))
if settings.VITE_DEV_MODE:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"]
@@ -309,6 +322,7 @@ class SecurityMiddleware(MiddlewareMixin):
if not settings.VITE_DEV_MODE:
# can't have 'unsafe-inline' and nonce at the same time
h['style-src'].append(nonce)
# Only include pay.google.com for wallet detection purposes on the Payment selection page
if (
url.url_name == "event.order.pay.change" or
@@ -317,27 +331,32 @@ class SecurityMiddleware(MiddlewareMixin):
h['script-src'].append('https://pay.google.com')
h['frame-src'].append('https://pay.google.com')
h['connect-src'].append('https://google.com/pay')
if settings.LOG_CSP:
h['report-uri'] = ["/csp_report/"]
if 'Content-Security-Policy' in resp:
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
if settings.CSP_ADDITIONAL_HEADER:
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
staticdomain = "'self'"
dynamicdomain = "'self'"
mediadomain = "'self'"
placeholders = {
"{static}": ["'self'"],
"{dynamic}": ["'self'"],
"{media}": ["'self'"],
}
if settings.MEDIA_URL.startswith('http'):
mediadomain += " " + settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]
placeholders["{media}"].append(settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)])
if settings.STATIC_URL.startswith('http'):
staticdomain += " " + settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]
placeholders["{static}"].append(settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)])
if settings.SITE_URL.startswith('http'):
if settings.SITE_URL.find('/', 9) > 0:
staticdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
dynamicdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
placeholders["{static}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)])
placeholders["{dynamic}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)])
else:
staticdomain += " " + settings.SITE_URL
dynamicdomain += " " + settings.SITE_URL
placeholders["{static}"].append(settings.SITE_URL)
placeholders["{dynamic}"].append(settings.SITE_URL)
if hasattr(request, 'organizer') and request.organizer:
if hasattr(request, 'event') and request.event:
@@ -348,18 +367,29 @@ class SecurityMiddleware(MiddlewareMixin):
siteurlsplit = urlsplit(settings.SITE_URL)
if siteurlsplit.port and siteurlsplit.port not in (80, 443):
domain = '%s:%d' % (domain, siteurlsplit.port)
dynamicdomain += " " + domain
placeholders["{dynamic}"].append(domain)
if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
media=mediadomain)
for k, v in h.items():
h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')))
resp['Content-Security-Policy'] = _render_csp(h)
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
for k, v in h.items():
h[k] = sorted(set(result for part in v for result in placeholders.get(part, [part])))
return resp
return h
def _get_font_origins(self, event):
def nested_dict_values(d):
for v in d.values():
if isinstance(v, dict):
yield from nested_dict_values(v)
else:
if isinstance(v, str):
yield v
font_src = set()
for font in get_fonts(event, pdf_support_required=False).values():
for path in list(nested_dict_values(font)):
font_location = urlparse(path)
if font_location.scheme and font_location.netloc:
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
return font_src
class RejectInvalidInputMiddleware(MiddlewareMixin):
@@ -370,16 +400,8 @@ class RejectInvalidInputMiddleware(MiddlewareMixin):
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
raise BadRequest("Invalid characters in input.")
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
try:
post_data = request.POST.lists()
except BadRequest:
# Reading request.POST wasn't possible, probably an invalid charset. Django will crash once we actually
# use request.POST, but if we don't, let's not crash it (required for some weird payment provider
# webhooks, e.g. computop).
pass
else:
if any("\x00" in value for key, value_list in post_data for value in value_list):
raise BadRequest("Invalid characters in input.")
if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
raise BadRequest("Invalid characters in input.")
class CustomCommonMiddleware(CommonMiddleware):
+14 -14
View File
@@ -373,7 +373,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
mail(
email or self.email,
_('Account information changed'),
_('Changes to your account'),
'pretixcontrol/email/security_notice.txt',
{
'user': self,
@@ -400,12 +400,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
with language(self.locale):
if reason == 'email_change':
msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
msg = str(_('To change your email address from {old_email} to {new_email}, use the following code:').format(
old_email=self.email, new_email=email,
))
elif reason == 'email_verify':
msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
msg = str(_('To verify your email address {email} on {instance}, use the following code:').format(
email=self.email,
instance=settings.PRETIX_INSTANCE_NAME,
))
else:
raise Exception('Invalid confirmation code reason')
@@ -418,7 +419,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
}
mail(
email or self.email,
_('pretix confirmation code'),
_('Your confirmation code'),
'pretixcontrol/email/confirmation_code.txt',
{
'user': self,
@@ -462,7 +463,9 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
from pretix.base.services.mail import mail
mail(
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
self.email,
_('Reset your password'),
'pretixcontrol/email/forgot.txt',
{
'instance': settings.PRETIX_INSTANCE_NAME,
'user': self,
@@ -647,25 +650,22 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True)
)
def has_active_staff_session(self, session_key=None):
def has_active_staff_session(self, session_key):
"""
Returns whether or not a user has an active staff session (formerly known as superuser session)
with the given session key.
"""
return self.get_active_staff_session(session_key) is not None
def get_active_staff_session(self, session_key=None):
if not self.is_staff:
def get_active_staff_session(self, session_key):
if not self.is_staff or not session_key:
return None
if not hasattr(self, '_staff_session_cache'):
self._staff_session_cache = {}
if session_key not in self._staff_session_cache:
qs = StaffSession.objects.filter(
user=self, date_end__isnull=True
)
if session_key:
qs = qs.filter(session_key=session_key)
sess = qs.first()
sess = StaffSession.objects.filter(
user=self, date_end__isnull=True, session_key=session_key
).first()
if sess:
if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE):
sess.date_end = now()
+1 -1
View File
@@ -395,7 +395,7 @@ class AttendeeProfile(models.Model):
self.company,
self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name,
self.country.name if self.country else None,
]
for a in self.answers:
value = a.get('value')
+8 -1
View File
@@ -40,6 +40,7 @@ import warnings
from collections import Counter, OrderedDict, defaultdict
from datetime import datetime, time, timedelta
from operator import attrgetter
from typing import TYPE_CHECKING
from urllib.parse import urljoin
from zoneinfo import ZoneInfo
@@ -79,10 +80,16 @@ from pretix.helpers.thumb import get_thumbnail
from ..settings import settings_hierarkey
from .organizer import Organizer, Team
if TYPE_CHECKING:
from hierarkey.proxy import HierarkeyProxy
logger = logging.getLogger(__name__)
class EventMixin:
if TYPE_CHECKING:
settings: HierarkeyProxy
def clean(self):
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
@@ -899,7 +906,7 @@ class Event(EventMixin, LoggedModel):
self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if hasattr(other, 'alternative_domain_assignment'):
if hasattr(other, 'alternative_domain_assignment') and not is_cross_organizer:
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
if not self.all_sales_channels:
-36
View File
@@ -885,26 +885,6 @@ class Item(LoggedModel):
return False
return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or time_machine_now()
subevent_item = subevent and subevent.item_overrides.get(self.pk)
if not self.active:
return 'active'
elif self.available_from and self.available_from > now_dt:
return 'available_from'
elif self.available_until and self.available_until < now_dt:
return 'available_until'
elif (self.require_voucher or self.hide_without_voucher) and not has_voucher:
return 'require_voucher'
elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt:
return 'available_from'
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
return 'available_until'
elif self.hidden_if_item_available and self._dependency_available:
return 'hidden_if_item_available'
else:
return None
def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
@@ -1413,22 +1393,6 @@ class ItemVariation(models.Model):
return False
return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or time_machine_now()
subevent_var = subevent and subevent.var_overrides.get(self.pk)
if not self.active:
return 'active'
elif self.available_from and self.available_from > now_dt:
return 'available_from'
elif self.available_until and self.available_until < now_dt:
return 'available_until'
elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt:
return 'available_from'
elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt:
return 'available_until'
else:
return None
@property
def meta_data(self):
data = self.item.meta_data
+2 -2
View File
@@ -1697,7 +1697,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
self.company,
self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name
self.country.name if self.country else ''
]
lines = [r.strip() for r in lines if r]
return '\n'.join(lines).strip()
@@ -3438,7 +3438,7 @@ class InvoiceAddress(models.Model):
self.name,
self.street,
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
self.country.name,
self.country.name if self.country else '',
self.vat_id,
self.custom_field,
self.internal_reference,
+7
View File
@@ -35,6 +35,7 @@ import operator
import string
from datetime import date, datetime, time
from functools import reduce
from typing import TYPE_CHECKING
import pytz_deprecation_shim
from django.conf import settings
@@ -61,6 +62,9 @@ from ...helpers.permission_migration import (
from ..settings import settings_hierarkey
from .auth import User
if TYPE_CHECKING:
from hierarkey.proxy import HierarkeyProxy
@settings_hierarkey.add(cache_namespace='organizer')
class Organizer(LoggedModel):
@@ -78,6 +82,9 @@ class Organizer(LoggedModel):
"""
settings_namespace = 'organizer'
if TYPE_CHECKING:
settings: HierarkeyProxy
name = models.CharField(max_length=200,
verbose_name=_("Name"))
slug = models.CharField(
+45 -38
View File
@@ -32,8 +32,10 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import datetime
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from typing import Union
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -421,27 +423,33 @@ class Voucher(LoggedModel):
return False
@staticmethod
def clean_quota_get_ignored(old_instance):
quotas = set()
was_valid = old_instance and (
old_instance.valid_until is None or old_instance.valid_until >= now()
)
if old_instance and old_instance.block_quota and was_valid:
if old_instance.quota:
quotas.add(old_instance.quota)
elif old_instance.variation:
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
elif old_instance.item:
if old_instance.item.has_variations:
quotas |= set(
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
itemvariation__item=old_instance.item,
quota__subevent=old_instance.subevent,
).values('quota_id'))
)
else:
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
return quotas
def get_affected_quotas(quota, item, variation, subevent):
if quota:
return {quota}
elif item and variation:
return set(variation.quotas.filter(subevent=subevent))
elif item and not item.has_variations:
return set(item.quotas.filter(subevent=subevent))
elif item and item.has_variations:
return set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=item,
quota__subevent=subevent,
).values('quota_id')
)
)
else:
return set()
@staticmethod
def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]):
if voucher_data:
valid = voucher_data.valid_until is None or voucher_data.valid_until >= now()
if valid and voucher_data.block_quota and voucher_data.max_usages > voucher_data.redeemed:
return Voucher.get_affected_quotas(voucher_data.quota, voucher_data.item, voucher_data.variation, voucher_data.subevent)
return set()
@staticmethod
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
@@ -453,22 +461,8 @@ class Voucher(LoggedModel):
if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
if quota:
new_quotas = {quota}
elif item and variation:
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
elif item and not item.has_variations:
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
elif item and item.has_variations:
new_quotas = set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=item,
quota__subevent=data.get('subevent'),
).values('quota_id')
)
)
else:
new_quotas = Voucher.get_affected_quotas(quota, item, variation, data.get('subevent'))
if not new_quotas:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.'))
@@ -644,3 +638,16 @@ class Voucher(LoggedModel):
]
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
return ops
@dataclass
class VoucherBulkData:
item: object
variation: object
quota: object
block_quota: bool
valid_until: datetime.datetime
subevent: object
redeemed: int
max_usages: int
allow_ignore_quota: bool
+1 -1
View File
@@ -834,7 +834,7 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str | None:
"""
After the user has confirmed their purchase, this method will be called to complete
the payment process. This is the place to actually move the money if applicable.
+10 -5
View File
@@ -77,6 +77,7 @@ from reportlab.platypus import Paragraph
from pretix.base.i18n import language
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
from pretix.base.services.placeholders import PlaceholderContext
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
@@ -401,11 +402,7 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Event organizer info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
}),
("event_info_text", {
"label": _("Event info text"),
"editor_sample": _("Event info text"),
"evaluate": lambda op, order, ev: str(order.event.settings.event_info_text)
}),
("event_info_text", {}), # Placeholder to "reserve" position, defined later in `get_variables`
("now_date", {
"label": _("Printing date"),
"editor_sample": _("2017-05-31"),
@@ -670,6 +667,14 @@ def get_images(event):
def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES)
templating_context = PlaceholderContext(event=event)
v['event_info_text'] = {
"label": _("Event info text"),
"editor_sample": _("Event info text"),
"evaluate": lambda op, order, ev:
templating_context.format(str(order.event.settings.event_info_text))
}
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
+3 -1
View File
@@ -22,6 +22,7 @@
import logging
from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
from django.utils.crypto import get_random_string
@@ -377,12 +378,13 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
confirmation_code = get_random_string(8, allowed_chars="01234567890")
mail(
user.email,
subject=gettext('Bulk-refund confirmation'),
subject=gettext('Confirm event cancellation and bulk refund'),
template='pretixbase/email/cancel_confirm.txt',
context={
"event": str(event),
"amount": money_filter(refund_total, event.currency),
"confirmation_code": confirmation_code,
"instance": settings.PRETIX_INSTANCE_NAME,
},
locale=user.locale,
)
+2 -2
View File
@@ -29,7 +29,7 @@ from typing import List
from django.utils.functional import cached_property
from pretix.base.models import CartPosition, ItemCategory, SalesChannel
from pretix.presale.views.event import get_grouped_items
from pretix.presale.productlist import prepare_item_list_for_shop
class DummyCategory:
@@ -162,7 +162,7 @@ class CrossSellingService:
]
def _prepare_items(self, subevent, items_qs, discount_info):
items, _btn = get_grouped_items(
items, _btn = prepare_item_list_for_shop(
self.event,
subevent=subevent,
voucher=None,
+2 -1
View File
@@ -340,12 +340,13 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter,
if schedule.owner.is_active:
mail(
email=schedule.owner.email,
subject=gettext('Export failed'),
subject=gettext('Scheduled export failed'),
template='pretixbase/email/export_failed.txt',
context={
'configuration_url': config_url,
'reason': msg,
'soft': soft,
'instance': settings.PRETIX_INSTANCE_NAME,
},
event=context if isinstance(context, Event) else None,
organizer=context.organizer if isinstance(context, Event) else context,
+33 -3
View File
@@ -26,8 +26,9 @@ from typing import List
from django.conf import settings as django_settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.utils import IntegrityError
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.utils.translation import gettext as _, ngettext
from pretix.base.i18n import language
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
@@ -260,6 +261,7 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
# shorter. We'll see what works better in reality…
vouchers = []
codes = set()
lock_seats = []
for i, record in enumerate(data):
try:
@@ -268,6 +270,14 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
if not record.get("code"):
raise ValidationError(_('A voucher cannot be created without a code.'))
code = record.get("code")
if code.upper() in codes:
raise ValidationError(
_('Voucher codes must be unique. Code "{code}" already exists in this import.').format(
code=code,
)
)
codes.add(code.upper())
Voucher.clean_item_properties(
record,
event,
@@ -286,8 +296,22 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
lock_seats.append(voucher.seat)
except (ValidationError, ImportError) as e:
raise DataImportError(
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
_('Invalid data in row {row}: {message}').format(row=i + 1, message=str(e))
)
existing_codes = Voucher.objects.filter(
event=event,
code__in=codes,
).values_list("code", flat=True)
if len(existing_codes):
raise DataImportError(
ngettext(
'Voucher codes must be unique. Import contains existing voucher code {code}.',
'Voucher codes must be unique. Import contains existing voucher codes {code}.',
len(existing_codes)
).format(
code=", ".join(existing_codes)
)
)
with transaction.atomic():
# We don't support quotas here, so we only need to lock if seats are in use
@@ -300,7 +324,13 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
save_logentries = []
for v in vouchers:
v.save()
try:
v.save()
except IntegrityError:
# should not happen as we check existing codes before, but we did not lock so we might have a race-condition
raise DataImportError(
_('Vouchers could not be imported, probably due to a voucher code already being in use.')
)
save_logentries.append(v.log_action(
'pretix.voucher.added',
user=user,
+42 -23
View File
@@ -110,6 +110,7 @@ from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval
from pretix.presale.productlist import prepare_item_list_for_shop
from pretix.testutils.middleware import debugflags_var
@@ -1943,13 +1944,18 @@ class OrderChangeManager:
:param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values),
``"count"``, and ``"price"``.
:param limit_main_positions: By default, the method works on all methods of the order. If you set this to a
:param limit_main_positions: By default, the method works on all positions of the order. If you set this to a
queryset or a list of positions, all other positions and their add-ons will be kept
untouched.
"""
if self._operations:
raise ValueError("Setting addons should be the first/only operation")
def _allowed_on_order_sales_channel(item_or_var, order):
return item_or_var.all_sales_channels or (
order.sales_channel.identifier in (s.identifier for s in item_or_var.limit_sales_channels.all())
)
# Prepare containers for min/max check of products
item_counts = Counter()
for p in self.order.positions.all():
@@ -2043,13 +2049,11 @@ class OrderChangeManager:
if not item.is_available() or (variation and not variation.is_available()):
raise OrderError(error_messages['unavailable'])
if not item.all_sales_channels:
if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if not _allowed_on_order_sales_channel(item, self.order):
raise OrderError(error_messages['unavailable'])
if variation and not variation.all_sales_channels:
if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if variation and not _allowed_on_order_sales_channel(variation, self.order):
raise OrderError(error_messages['unavailable'])
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
raise OrderError(error_messages['not_for_sale'])
@@ -2097,6 +2101,36 @@ class OrderChangeManager:
)
item_counts[item] += 1
def _addon_is_available(a):
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always set voucher=None because that's what's done when generating the form in
# OrderChangeMixin (vouchers for addons are not supported).
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
if not _allowed_on_order_sales_channel(a.item, self.order) or (
a.variation and not _allowed_on_order_sales_channel(a.variation, self.order)
):
return False
items, _ = prepare_item_list_for_shop(
self.order.event,
channel=self.order.sales_channel,
subevent=a.subevent,
voucher=None,
base_qs=Item.objects.filter(pk=a.item.pk),
allow_addons=True
)
if (not items) or items[0].current_unavailability_reason:
return False
if a.variation:
variations = [var for var in items[0].available_variations if var.pk == a.variation.pk]
if (not variations) or variations[0].current_unavailability_reason:
return False
return True
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
@@ -2106,22 +2140,7 @@ class OrderChangeManager:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not a.item.all_sales_channels and
not a.item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
if not _addon_is_available(a):
# "Re-select" add-on
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
continue
+4 -3
View File
@@ -44,7 +44,7 @@ from django.conf import settings
from django.utils.crypto import get_random_string
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, User, cachedfile_name
@@ -171,15 +171,16 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
if user:
with language(user.locale):
event_name = str(event.name)
mail(
user.email,
_('Data shredding completed'),
gettext('Data shredding completed for %(event)s') % {'event': event_name},
'pretixbase/email/shred_completed.txt',
{
'instance': settings.PRETIX_INSTANCE_NAME,
'user': user,
'organizer': event.organizer.name,
'event': str(event.name),
'event': event_name,
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
},
+12 -3
View File
@@ -1276,7 +1276,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'write_permission': 'event.settings.invoicing:write',
'form_kwargs': dict(
label=_("Allow to update existing invoices"),
label=_("Allow updating existing invoices"),
help_text=_("By default, invoices can never again be changed once they are issued. In most countries, we "
"recommend to leave this option turned off and always issue a new invoice if a change needs "
"to be made."),
@@ -1924,8 +1924,6 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Hide all past dates from calendar"),
help_text=_("This option currently only affects the calendar of this event series, not the organizer-wide "
"calendar.")
)
},
'allow_modifications': {
@@ -2286,6 +2284,17 @@ DEFAULTS = {
help_text=_("We'll show this publicly to allow attendees to contact you.")
)
},
'contact_url': {
'default': None,
'type': str,
'serializer_class': serializers.URLField,
'form_class': forms.URLField,
'form_kwargs': dict(
label=_("Contact URL"),
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
)
},
'imprint_url': {
'default': None,
'type': str,
@@ -1,10 +1,14 @@
{% load i18n %}
{% trans "You have requested us to cancel an event which includes a larger bulk-refund:" %}
{% trans "You requested to cancel an event that involves a large bulk refund:" %}
{% trans "Event" %}: {{ event }}
- {% trans "Event" %}: {{ event }}
- {% trans "Estimated refund" %}: **{{ amount }}**
{% trans "Estimated refund amount" %}: **{{ amount }}**
{% trans "To confirm, paste the following code into the cancellation form:" %}
{% trans "Please confirm that you want to proceed by coping the following confirmation code into the cancellation form:" %}
{{ confirmation_code }}
**{{ confirmation_code }}**
{% blocktrans with instance=instance %}Don't share this code with anyone. The {{ instance }} team will never ask you for it.{% endblocktrans %}
{% blocktrans with instance=instance %}Thanks,
The {{ instance }} Team{% endblocktrans %}
@@ -1,12 +1,13 @@
{% load i18n %}
{% trans "Your export failed." %}
{% trans "Your scheduled export failed." %}
{% trans "Reason:" %} {{ reason }}
- {% trans "Reason" %}: {{ reason }}
{% if not soft %}
{% trans "If your export fails five times in a row, it will no longer be sent." %}
{% endif %}
{% if not soft %}{% trans "If an export fails five times in a row, we'll stop sending it." %}{% endif %}
{% trans "Configuration link:" %}
{% trans "You can adjust or remove this export here:" %}
{{ configuration_url }}
{% blocktrans with instance=instance %}Thanks,
The {{ instance }} Team{% endblocktrans %}
@@ -52,13 +52,13 @@
<table cellpadding="20"><tr><td>
<![endif]-->
<div class="content">
{% trans "You receive these emails based on your notification settings." %}<br>
{% trans "You're receiving this email based on your notification settings." %}<br>
<a href="{{ settings_url }}">
{% trans "Click here to view and change your notification settings" %}
{% trans "Manage settings" %}
</a>
{% if disable_url %}<br>
<a href="{{ disable_url }}">
{% trans "Click here disable all notifications immediately." %}
{% trans "Disable all notifications" %}
</a>
{% endif %}
</div>
@@ -1,19 +1,21 @@
{% load i18n %}
{{ notification.title }}{% if notification.detail %}
{{ notification.detail }}
{% endif %}{% if notification.url %}
{{ notification.detail }}{% endif %}{% if notification.url %}
{{ notification.url }}{% endif %}{% for attr in notification.attributes %}
{{ notification.url }}{% endif %}{% if notification.attributes %}
{{ attr.title }}: {{ attr.value }}{% endfor %}{% for action in notification.actions %}
{% for attr in notification.attributes %}- {{ attr.title }}: {{ attr.value }}
{% endfor %}{% endif %}{% for action in notification.actions %}
{{ action.label }}
{{ action.url }}{% endfor %}
{{ action.label }}:
{% trans "You receive these emails based on your notification settings." %}
{% trans "Click here to view and change your notification settings:" %}
{{ settings_url }}
{% if disable_url %}{% trans "Click here disable all notifications immediately:" %}
{{ disable_url }}
{{ action.url }}{% endfor %}
---
{% trans "You're receiving this email based on your notification settings." %}
- {% trans "Manage settings" %}: {{ settings_url }}
{% if disable_url %}- {% trans "Disable all notifications" %}: {{ disable_url }}
{% endif %}
@@ -1,17 +1,14 @@
{% load i18n %}
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
{% load i18n %}{% blocktrans %}Hello,
we hereby confirm that the following data shredding job has been completed:
The following data shredding job has been completed:
Organizer: {{ organizer }}
- Organizer: {{ organizer }}
- Event: {{ event }}
- Data selection: {{ shredders }}
- Start time: {{ start_time }}
Event: {{ event }}
Any data added to the event after the start time may not have been deleted.
Data selection: {{ shredders }}
Start time: {{ start_time }} (new data added after this time might not have been deleted)
Best regards,
Your {{ instance }} team
Thanks,
The {{ instance }} Team
{% endblocktrans %}
@@ -0,0 +1,28 @@
{% extends "error.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load static %}
{% block content %}
<h1>{% trans "Please continue in a new tab" %}</h1>
<p class="larger">
{% blocktrans trimmed %}
For security reasons, the following step is only possible in a new tab.
{% endblocktrans %}
</p>
<p class="larger">
{% blocktrans trimmed %}
If the new tab did not open automatically, please click the following button:
{% endblocktrans %}
</p>
<div class="text-center">
<a href="{{ url }}"
class="btn btn-primary btn-lg" target="_blank">
<span class="fa fa-external-link-square"></span>
{% trans "Continue in new tab" %}
</a>
{{ url|json_script:"framebreak-url" }}
<script type="text/javascript" src="{% static "pretixbase/js/framebreak.js" %}"></script>
</div>
{% endblock %}
+2 -4
View File
@@ -42,8 +42,6 @@ from bleach import DEFAULT_CALLBACKS, html5lib_shim
from bleach.linkifier import build_email_re
from django import template
from django.conf import settings
from django.core import signing
from django.urls import reverse
from django.utils.functional import SimpleLazyObject
from django.utils.html import escape
from django.utils.http import url_has_allowed_host_and_scheme
@@ -54,6 +52,7 @@ from markdown.postprocessors import Postprocessor
from markdown.treeprocessors import UnescapeTreeprocessor
from tlds import tld_set
from pretix.base.views.redirect import safelink
from pretix.helpers.format import SafeFormatter, format_map
register = template.Library()
@@ -158,8 +157,7 @@ def safelink_callback(attrs, new=False):
"""
url = html.unescape(attrs.get((None, 'href'), '/'))
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
signer = signing.Signer(salt='safe-redirect')
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
attrs[None, 'href'] = safelink(url)
attrs[None, 'target'] = '_blank'
attrs[None, 'rel'] = 'noopener'
return attrs
+29 -6
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/>.
#
import logging
import urllib.parse
from django.core import signing
@@ -26,6 +27,8 @@ from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
logger = logging.getLogger(__name__)
def _is_samesite_referer(request):
referer = request.headers.get('referer')
@@ -42,11 +45,16 @@ def _is_samesite_referer(request):
def redir_view(request):
signer = signing.Signer(salt='safe-redirect')
framebreak = "framebreak" in request.GET
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
try:
url = signer.unsign(request.GET.get('url', ''))
url = signing.Signer(salt=salt).unsign(request.GET.get('url', ''))
except signing.BadSignature:
return HttpResponseBadRequest('Invalid parameter')
try:
# Backwards-compatibility for a change in 2026-06, remove after a while
url = signing.Signer(salt='safe-redirect').unsign(request.GET.get('url', ''))
except signing.BadSignature:
return HttpResponseBadRequest('Invalid parameter')
if not _is_samesite_referer(request):
u = urllib.parse.urlparse(url)
@@ -55,11 +63,26 @@ def redir_view(request):
'url': url,
})
if framebreak:
r = render(request, 'pretixbase/framebreak.html', {
'url': url,
})
r.xframe_options_exempt = True
return r
r = HttpResponseRedirect(url)
r['X-Robots-Tag'] = 'noindex'
return r
def safelink(url):
signer = signing.Signer(salt='safe-redirect')
return reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
def safelink(url, framebreak=False):
url = str(url)
if not (url.startswith('https://') or url.startswith('http://') or url.startswith("/")):
logger.warning('Invalid URL passed to safelink: %r', url)
return '#invalid-url'
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
signer = signing.Signer(salt=salt)
u = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
if framebreak:
u += "&framebreak=true"
return u
+43 -1
View File
@@ -19,14 +19,56 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
import os
from celery import Celery
from celery import Celery, signals
from django.dispatch import receiver
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
logger = logging.getLogger(__name__)
from django.conf import settings
app = Celery('pretix')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
@receiver(signals.before_task_publish)
def on_before_task_publish(sender, body, exchange, routing_key, headers, properties, declare, retry_policy, **kwargs):
from pretix.helpers.logs import local
trace = getattr(local, 'trace', [])
request_id = getattr(local, 'request_id', None)
if request_id:
trace.append(request_id)
headers["X-Pretix-Trace"] = " ".join(trace)
@receiver(signals.task_received)
def on_task_received(sender, request, **kwargs):
trace = request._request_dict.get("X-Pretix-Trace")
if trace:
logger.info(f"Task {request.id} has trace {trace}")
@receiver(signals.task_prerun)
def on_task_prerun(sender, task_id, task, **kwargs):
from pretix.helpers.logs import local
local.request_id = task_id
if "X-Pretix-Trace" in task.request.headers:
local.trace = task.request.headers["X-Pretix-Trace"].split(" ")
else:
local.trace = []
local.trace.append(task_id)
@receiver(signals.task_postrun)
def on_task_postrun(sender, task_id, task, **kwargs):
from pretix.helpers.logs import local
local.request_id = None
local.trace = []
+13 -1
View File
@@ -625,6 +625,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'max_items_per_order',
'reservation_time',
'contact_mail',
'contact_url',
'show_variations_expanded',
'hide_sold_out',
'meta_noindex',
@@ -671,6 +672,11 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
base_context = {
'frontpage_text': ['event'],
'presale_has_ended_text': ['event'],
'voucher_explanation_text': ['event'],
'banner_text': ['event'],
'banner_text_bottom': ['event'],
'event_info_text': ['event'],
}
def _resolve_virtual_keys_input(self, data, prefix=''):
@@ -1673,7 +1679,7 @@ class CountriesAndEUAndStates(CountriesAndEU):
class TaxRuleLineForm(I18nForm):
country = LazyTypedChoiceField(
choices=CountriesAndEUAndStates(),
choices=lazy(lambda: CountriesAndEUAndStates(), CountriesAndEUAndStates),
required=False
)
address_type = forms.ChoiceField(
@@ -1899,6 +1905,12 @@ class QuickSetupForm(I18nForm):
required=False,
help_text=_("We'll show this publicly to allow attendees to contact you.")
)
contact_url = forms.URLField(
label=_("Contact URL"),
required=False,
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
)
total_quota = forms.IntegerField(
label=_("Total capacity"),
min_value=0,
+1
View File
@@ -604,6 +604,7 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'contact_url',
'imprint_url',
'organizer_info_text',
'event_list_type',
+280 -51
View File
@@ -32,16 +32,20 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import copy
import csv
from collections import namedtuple
from collections import Counter, namedtuple
from io import StringIO
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import EmailValidator
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.translation import gettext_lazy as _
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
from pretix.base.email import get_available_placeholders
@@ -50,7 +54,10 @@ from pretix.base.forms import (
)
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.i18n import language
from pretix.base.models import Item, Voucher
from pretix.base.models import Item, ItemVariation, Quota, SubEvent, Voucher
from pretix.base.models.vouchers import VoucherBulkData
from pretix.base.services.locking import lock_objects
from pretix.base.services.quotas import QuotaAvailability
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import voucher_form_validation
@@ -105,15 +112,22 @@ class VoucherForm(I18nModelForm):
except Item.DoesNotExist:
pass
super().__init__(*args, **kwargs)
if not self.event and self.instance:
self.event = self.instance.event
if instance.event.has_subevents:
self.fields['subevent'].queryset = instance.event.subevents.all()
self.fields['tag'].widget.attrs['data-typeahead-url'] = reverse('control:event.vouchers.tags.typeahead', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
})
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
}
)
@@ -123,18 +137,19 @@ class VoucherForm(I18nModelForm):
del self.fields['subevent']
choices = []
if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
iv = self.data.get('itemvar') or initial.get('itemvar', '')
prefix = (self.prefix + '-') if self.prefix else ''
if 'itemvar' in initial or (self.data and prefix + 'itemvar' in self.data):
iv = self.data.get(prefix + 'itemvar', '') or initial.get('itemvar', '') or ''
if iv.startswith('q-'):
q = self.instance.event.quotas.get(pk=iv[2:])
q = self.event.quotas.get(pk=iv[2:])
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
elif '-' in iv:
itemid, varid = iv.split('-')
i = self.instance.event.items.get(pk=itemid)
i = self.event.items.get(pk=itemid)
v = i.variations.get(pk=varid)
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
elif iv:
i = self.instance.event.items.get(pk=iv)
i = self.event.items.get(pk=iv)
if i.variations.exists():
choices.append((str(i.pk), _('{product} Any variation').format(product=i)))
else:
@@ -145,8 +160,8 @@ class VoucherForm(I18nModelForm):
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('All products')
}
@@ -154,7 +169,7 @@ class VoucherForm(I18nModelForm):
self.fields['itemvar'].required = False
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
if self.event.seating_plan or self.event.subevents.filter(seating_plan__isnull=False).exists():
self.fields['seat'] = forms.CharField(
label=_("Specific seat ID"),
max_length=255,
@@ -164,40 +179,45 @@ class VoucherForm(I18nModelForm):
help_text=str(self.instance.seat) if self.instance.seat else '',
)
def parse_itemvar(self, data):
try:
itemid = quotaid = None
iv = data.get('itemvar', '')
if iv.startswith('q-'):
quotaid = iv[2:]
elif '-' in iv:
itemid, varid = iv.split('-')
elif iv:
itemid, varid = iv, None
else:
itemid, varid = None, None
if itemid:
item = self.event.items.get(pk=itemid)
if varid:
variation = item.variations.get(pk=varid)
else:
variation = None
quota = None
elif quotaid:
quota = self.event.quotas.get(pk=quotaid)
item = None
variation = None
else:
quota = None
item = None
variation = None
return (item, variation, quota)
except ObjectDoesNotExist:
raise ValidationError(_("Invalid product selected."))
def clean(self):
data = super().clean()
if not self._errors:
try:
itemid = quotaid = None
iv = self.data.get('itemvar', '')
if iv.startswith('q-'):
quotaid = iv[2:]
elif '-' in iv:
itemid, varid = iv.split('-')
elif iv:
itemid, varid = iv, None
else:
itemid, varid = None, None
if itemid:
self.instance.item = self.instance.event.items.get(pk=itemid)
if varid:
self.instance.variation = self.instance.item.variations.get(pk=varid)
else:
self.instance.variation = None
self.instance.quota = None
elif quotaid:
self.instance.quota = self.instance.event.quotas.get(pk=quotaid)
self.instance.item = None
self.instance.variation = None
else:
self.instance.quota = None
self.instance.item = None
self.instance.variation = None
except ObjectDoesNotExist:
raise ValidationError(_("Invalid product selected."))
self.instance.item, self.instance.variation, self.instance.quota = self.parse_itemvar(self.data)
if 'codes' in data:
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
@@ -209,7 +229,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_item_properties(
data, self.instance.event,
data, self.event,
self.instance.quota, self.instance.item, self.instance.variation,
seats_given=data.get('seat') or data.get('seats'),
block_quota=data.get('block_quota')
@@ -229,7 +249,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_subevent(
data, self.instance.event
data, self.event
)
except ValidationError as e:
raise ValidationError({"subevent": e.message})
@@ -245,19 +265,19 @@ class VoucherForm(I18nModelForm):
if check_quota:
Voucher.clean_quota_check(
data, cnt, self.initial_instance_data,
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
self.event, self.instance.quota, self.instance.item, self.instance.variation
)
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
Voucher.clean_voucher_code(data, self.event, self.instance.pk)
if 'seat' in self.fields:
if data.get('seat'):
self.instance.seat = Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
data, self.instance.item, self.instance.quota, self.event, self.instance.pk
)
self.instance.item = self.instance.seat.product
else:
self.instance.seat = None
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
voucher_form_validation.send(sender=self.event, form=self, data=data)
return data
@@ -265,6 +285,215 @@ class VoucherForm(I18nModelForm):
return super().save(commit)
class VoucherBulkEditForm(VoucherForm):
def __init__(self, *args, **kwargs):
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(**kwargs)
del self.fields["code"]
self.fields.pop("seat", None)
def is_bulk_checked(self, fieldname):
return self.prefix + fieldname in self.data.getlist('_bulk')
def clean(self):
# We skip the parent class because it's not suited for bulk editing and implement custom validation here.
# This does not validate *everything* we validate in VoucherForm. For example, we skip validation that one does
# not create a voucher for an add-on product or that the seat matches the product to save on complexity.
# This is a UX validation only anyway, since one could first create the voucher and then make the product an
# add-on product. However, we need to validate everything that we don't want violated in the database.
data = super(VoucherForm, self).clean()
if self.is_bulk_checked("itemvar"):
data["item"], data["variation"], data["quota"] = self.parse_itemvar(data)
if self.is_bulk_checked("max_usages") and "max_usages" in data:
max_redeemed = self.queryset.aggregate(m=Max("redeemed"))["m"]
if data["max_usages"] < max_redeemed:
raise ValidationError(_(
"You cannot reduce the maximum number of redemptions to %(max_usages)s, because at least one "
"of the selected vouchers has already been redeemed %(max_redeemed)s times."
) % {"max_usages": data["max_usages"], "max_redeemed": max_redeemed})
# Check diff on product and quota usage based on old groups of vouchers
if any(self.is_bulk_checked(k) for k in ("max_usages", "itemvar", "block_quota", "valid_until", "subevent")):
quota_diff = Counter()
current_vouchers = self.queryset.order_by().values(
"item", "variation", "quota", "block_quota", "valid_until", "subevent", "redeemed", "max_usages",
"allow_ignore_quota",
).annotate(c=Count("*"))
item_cache = {i.pk: i for i in Item.objects.filter(pk__in=[c["item"] for c in current_vouchers])}
var_cache = {v.pk: v for v in ItemVariation.objects.filter(pk__in=[c["variation"] for c in current_vouchers])}
quota_cache = {q.pk: q for q in Quota.objects.filter(pk__in=[c["quota"] for c in current_vouchers])}
subevent_cache = {s.pk: s for s in SubEvent.objects.filter(pk__in=[c["subevent"] for c in current_vouchers])}
for current in current_vouchers:
bulk_count = current.pop('c')
current = VoucherBulkData(**current)
# Get quotas that are currently used
if current.item:
current.item = item_cache[current.item]
if current.variation:
current.variation = var_cache[current.variation]
if current.quota:
current.quota = quota_cache[current.quota]
if current.subevent:
current.subevent = subevent_cache[current.subevent]
old_quotas = Voucher.clean_quota_get_ignored(current)
old_amount = max(current.max_usages - current.redeemed, 0) * bulk_count
# Predict state after change
after_change = copy.copy(current)
if self.is_bulk_checked("itemvar") and "itemvar" in data:
after_change.item = data["item"]
after_change.variation = data["variation"]
after_change.quota = data["quota"]
if self.is_bulk_checked("subevent") and "subevent" in data:
after_change.subevent = data["subevent"]
if self.is_bulk_checked("max_usages") and "max_usages" in data:
after_change.max_usages = data["max_usages"]
if self.is_bulk_checked("block_quota") and "block_quota" in data:
after_change.block_quota = data["block_quota"]
if self.is_bulk_checked("valid_until") and "valid_until" in data:
after_change.valid_until = data["valid_until"]
if self.is_bulk_checked("allow_ignore_quota") and "allow_ignore_quota" in data:
after_change.allow_ignore_quota = data["allow_ignore_quota"]
if after_change.quota and self.event.has_subevents and not after_change.subevent:
raise ValidationError(_("You cannot create a voucher that allows selection of a quota but has no date selected."))
if after_change.quota and after_change.subevent and after_change.quota.subevent_id != after_change.subevent.pk:
raise ValidationError(_("The selected quota does not match the selected subevent."))
if after_change.block_quota and self.event.has_subevents and not after_change.subevent:
raise ValidationError(
_('If you want this voucher to block quota, you need to select a specific date.'))
if after_change.block_quota and not after_change.item and not after_change.quota:
raise ValidationError(
_('You need to select a specific product or quota if this voucher should reserve '
'tickets.')
)
if after_change.allow_ignore_quota:
# todo: is this the most useful way to do this?
continue
new_quotas = Voucher.clean_quota_get_ignored(after_change)
new_amount = max(after_change.max_usages - after_change.redeemed, 0) * bulk_count
if new_quotas != old_quotas or new_amount != old_amount:
for q in old_quotas:
quota_diff[q] -= old_amount
for q in new_quotas:
quota_diff[q] += new_amount
if any(v > 0 for q, v in quota_diff.items()):
lock_objects([q for q, v in quota_diff.items() if q.size is not None and v > 0], shared_lock_objects=[self.event])
qa = QuotaAvailability(count_waitinglist=False)
qa.queue(*(q for q, v in quota_diff.items() if v > 0))
qa.compute()
if any(qa.results[q][0] != Quota.AVAILABILITY_OK or (qa.results[q][1] is not None and qa.results[q][1] < required)
for q, required in quota_diff.items() if required > 0):
raise ValidationError(_(
'There is no sufficient quota available to perform this change.'
))
has_seat = self.queryset.filter(seat__isnull=False).exists()
if has_seat:
if self.is_bulk_checked("max_usages"):
raise ValidationError(_(
'Changing the maximum number of usages in bulk is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("subevent"):
raise ValidationError(pgettext_lazy(
'subevent',
'Changing the date in bulk is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("itemvar") and data["quota"]:
raise ValidationError(_(
'Changing the product to a quota is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("valid_until"):
if data["valid_until"] is None or data["valid_until"] >= now():
currently_not_blocked_seats = self.queryset.filter(
seat__isnull=False,
max_usages__gt=F("redeemed"),
valid_until__lt=now(),
)
if self.event.has_subevents:
subevents = self.event.subevents.filter(pk__in=currently_not_blocked_seats.values_list("subevent"))
for se in subevents:
conflicts = currently_not_blocked_seats.filter(
subevent=se
).exclude(
seat_id__in=se.free_seats().values("pk")
)
if conflicts:
raise ValidationError(_(
'This change cannot be completed because not all assigned seats of the vouchers are '
'still available'
))
else:
conflicts = currently_not_blocked_seats.exclude(
seat_id__in=self.event.free_seats().values("pk")
)
if conflicts:
raise ValidationError(_(
'This change cannot be completed because not all assigned seats of the vouchers are '
'still available'
))
return data
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
check_map = {
'price_mode': '__price',
'value': '__price',
}
for k in self.fields:
if not self.is_bulk_checked(check_map.get(k, k)):
continue
if k == 'itemvar':
fields.add("item")
fields.add("variation")
fields.add("quota")
else:
fields.add(k)
for obj in objs:
if k == 'itemvar':
obj.item = self.cleaned_data["item"]
obj.variation = self.cleaned_data["variation"]
obj.quota = self.cleaned_data["quota"]
else:
setattr(obj, k, self.cleaned_data[k])
fields = [f for f in fields if f != 'itemvars']
if fields:
Voucher.objects.bulk_update(objs, fields, 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
def _post_clean(self):
pass # skip model-level clean
class VoucherBulkForm(VoucherForm):
codes = forms.CharField(
widget=forms.Textarea,
+28 -11
View File
@@ -36,7 +36,8 @@ from urllib.parse import quote, urljoin, urlparse
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
from django.http import Http404
from django.contrib.auth.views import redirect_to_login
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, resolve_url
from django.template.response import TemplateResponse
from django.urls import get_script_prefix, resolve, reverse
@@ -97,6 +98,8 @@ class PermissionMiddleware:
super().__init__()
def _login_redirect(self, request):
from django.contrib.auth.views import redirect_to_login
# Taken from django/contrib/auth/decorators.py
path = request.build_absolute_uri()
# urlparse chokes on lazy objects in Python 3, force to str
@@ -109,10 +112,21 @@ class PermissionMiddleware:
if ((not login_scheme or login_scheme == current_scheme) and
(not login_netloc or login_netloc == current_netloc)):
path = request.get_full_path()
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME)
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# It's not useful to return a 302 redirect on a XMLHttpRequest request, because
# the XMLHttpRequest is unable to detect redirects.
return HttpResponse(
"Authentication required",
status=401,
headers={
# Appending ?next= is handled by client, because it should be the top-level context url,
# not the URL called in the background
"X-Login-Url": resolved_login_url,
}
)
return redirect_to_login(path, resolved_login_url, REDIRECT_FIELD_NAME)
def __call__(self, request):
url = resolve(request.path_info)
@@ -212,14 +226,17 @@ class AuditLogMiddleware:
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
if getattr(request.user, "is_hijacked", False):
hijack_history = request.session.get('hijack_history', False)
hijacker = get_object_or_404(User, pk=hijack_history[0])
hijacker = get_object_or_404(User, pk=hijack_history[0]["user"])
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
if ss:
ss.logs.create(
url=request.path,
method=request.method,
impersonating=request.user
)
if not ss:
# Staff session expired or not found
logout(request)
return redirect_to_login(request.get_full_path())
ss.logs.create(
url=request.path,
method=request.method,
impersonating=request.user
)
else:
ss = request.user.get_active_staff_session(request.session.session_key)
if ss:
@@ -56,5 +56,4 @@
</form>
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
{% endblock %}
@@ -1,13 +1,13 @@
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
{% load i18n %}{% blocktrans with code=code reason=reason instance=instance %}Hello,
{{ reason }}
{{ code }}
Please do never give this code to another person. Our support team will never ask for this code.
Don't share this code with anyone. The {{ instance }} team will never ask you for it.
If this code was not requested by you, please contact us immediately.
If you didn't request this code, please contact us immediately.
Best regards,
Your {{ instance }} team
Thanks,
The {{ instance }} Team
{% endblocktrans %}
@@ -1,14 +1,15 @@
{% load i18n %}{% blocktrans with code=code instance=instance %}Hello,
{% load i18n %}{% blocktrans with code=code address=address instance=instance %}Hello,
someone requested to use {{ address }} as a sender address on {{ instance }}.
This will allow them to send emails that are shown to originate from this email address.
If that was you, please enter the following confirmation code:
Someone requested to use {{ address }} as a sender address on {{ instance }}. Once verified, emails sent from {{ instance }} can show this address as the sender.
{{ code }}
If this was you, enter the following code in the setup form:
If this was not requested by you, you can safely ignore this email.
{{ code }}
Best regards,
Don't share this code with anyone unless you want to authorize them to use this address for this purpose. The {{ instance }} team will never ask you for it.
Your {{ instance }} team
{% endblocktrans %}
If you didn't request this, you can safely ignore this email.
Thanks,
The {{ instance }} Team
{% endblocktrans %}
@@ -1,9 +1,11 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
you requested a new password. Please go to the following page to reset your password:
We received a request to reset the password for your {{ instance }} account. To choose a new password, follow the link below:
{{ url }}
Best regards,
Your {{ instance }} team
If you didn't request this, you can safely ignore this email — your password won't change.
Thanks,
The {{ instance }} Team
{% endblocktrans %}
@@ -1,17 +1,16 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
you have been invited to a team on {{ instance }}, a platform to perform event
ticket sales.
You've been invited to join a team on {{ instance }}, an event ticket sales platform.
Organizer: {{ organizer }}
Team: {{ team }}
- Organizer: {{ organizer }}
- Team: {{ team }}
To accept, follow the link below:
If you want to join that team, just click on the following link:
{{ url }}
If you do not want to join, you can safely ignore or delete this email.
If you don't want to join, you can safely ignore this email.
Best regards,
Your {{ instance }} team
Thanks,
The {{ instance }} Team
{% endblocktrans %}
@@ -1,13 +1,19 @@
{% load i18n %}{% blocktrans with url=url|safe os=source.os_type agent=source.agent_type %}Hello,
{% load i18n %}{% blocktrans %}Hello,
a login to your {{ instance }} account from an unusual or new location was detected. The login was performed using {{ agent }} on {{ os }} from {{ country }}.
We noticed a new sign-in to your {{ instance }} account:
{% endblocktrans %}
- {% trans "Time" %}: {{ when }}
- {% trans "Browser" %}: {{ agent }}
- {% trans "Operating system" %}: {{ os }}
{% if device %}- {% trans "Device" %}: {{ device }}
{% endif %}{% if country %}- {% trans "Country" %}: {{ country }}
{% endif %}
{% blocktrans with url=url|safe %}If it was you, no action is needed.
If this was you, you can safely ignore this email.
If this was not you, we recommend that you change your password in your account settings:
If you don't recognize this sign-in, please change your password immediately:
{{ url }}
Best regards,
Your {{ instance }} team
Thanks,
The {{ instance }} Team
{% endblocktrans %}
@@ -1,16 +1,15 @@
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
this is to inform you that the account information of your {{ instance }} account has been
changed. In particular, the following changes have been performed:
The following changes were made to your {{ instance }} account:
{{ messages }}
If this change was not performed by you, please contact us immediately.
If you didn't make these changes, please contact the {{ instance }} support team immediately.
You can review and change your account settings here:
You can review your account settings here:
{{ url }}
Best regards,
Your {{ instance }} team
Thanks,
The {{ instance }} Team
{% endblocktrans %}
@@ -193,6 +193,7 @@
{% endblocktrans %}
</p>
{% bootstrap_field form.contact_mail layout="control" %}
{% bootstrap_field form.contact_url layout="control" %}
{% bootstrap_field form.imprint_url layout="control" %}
</div>
</fieldset>
@@ -30,6 +30,7 @@
{% bootstrap_field form.date_admission layout="control" %}
{% bootstrap_field form.currency layout="control" %}
{% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.contact_url layout="control" %}
{% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% bootstrap_field form.all_sales_channels layout="control" %}
@@ -34,6 +34,7 @@
{% endif %}
{% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.contact_url layout="control" %}
{% bootstrap_field sform.organizer_info_text layout="control" %}
{% bootstrap_field sform.event_team_provisioning layout="control" %}
{% if sform.allowed_restricted_plugins %}
@@ -0,0 +1,83 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load eventsignal %}
{% load eventurl %}
{% block title %}{% trans "Change multiple vouchers" %}{% endblock %}
{% block inside %}
<h1>
{% trans "Change multiple vouchers" %}
<small>
{% blocktrans trimmed with number=vouchers.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<div class="hidden">
{% for v in vouchers %}
<input type="hidden" name="voucher" value="{{ v.pk }}">
{% endfor %}
</div>
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Voucher details" %}</legend>
{% bootstrap_field form.max_usages layout="bulkedit" %}
{% bootstrap_field form.valid_until layout="bulkedit" %}
{% bootstrap_field form.itemvar layout="bulkedit" %}
<div class="bulk-edit-field-group">
<label class="field-toggle">
<input type="checkbox" name="_bulk" value="{{ form.prefix }}__price" {% if form.prefix|add:"__price" in bulk_selected %}checked{% endif %}>
{% trans "change" context "form_bulk" %}
</label>
<div class="field-content">
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="controls">
<div class="alert alert-info">
{% blocktrans trimmed %}
If you choose "any product" for a specific quota and choose to reserve quota for this
voucher above, the product can still be unavailable to the voucher holder if another quota
associated with the product is sold out!
{% endblocktrans %}
</div>
</div>
</div>
</div>
{% if form.subevent %}
{% bootstrap_field form.subevent layout="bulkedit" %}
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.block_quota layout="bulkedit" %}
{% bootstrap_field form.allow_ignore_quota layout="bulkedit" %}
{% bootstrap_field form.min_usages layout="bulkedit" %}
{% bootstrap_field form.budget addon_after=request.event.currency layout="bulkedit" %}
{% bootstrap_field form.tag layout="bulkedit" %}
{% bootstrap_field form.comment layout="bulkedit" %}
{% bootstrap_field form.show_hidden_items layout="bulkedit" %}
{% bootstrap_field form.all_addons_included layout="bulkedit" %}
{% bootstrap_field form.all_bundles_included layout="bulkedit" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -99,6 +99,9 @@
</p>
<form action="{% url "control:event.vouchers.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
{% endfor %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
@@ -144,6 +147,18 @@
{% endif %}
<th></th>
</tr>
{% if "event.vouchers:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all" data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="5">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for v in vouchers %}
@@ -211,6 +226,10 @@
<i class="fa fa-trash" aria-hidden="true"></i>
{% trans "Delete selected" %}
</button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
formaction="{% url "control:event.vouchers.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>
</div>
{% endif %}
</form>
+2
View File
@@ -370,6 +370,7 @@ urlpatterns = [
re_path(r'^discounts/add$', discounts.DiscountCreate.as_view(), name='event.items.discounts.add'),
re_path(r'^vouchers/$', vouchers.VoucherList.as_view(), name='event.vouchers'),
re_path(r'^vouchers/tags/$', vouchers.VoucherTags.as_view(), name='event.vouchers.tags'),
re_path(r'^vouchers/tags/typeahead$', typeahead.voucher_tag_typeahead, name='event.vouchers.tags.typeahead'),
re_path(r'^vouchers/rng$', vouchers.VoucherRNG.as_view(), name='event.vouchers.rng'),
re_path(r'^vouchers/item_select$', typeahead.itemvarquota_select2, name='event.vouchers.itemselect2'),
re_path(r'^vouchers/(?P<voucher>\d+)/$', vouchers.VoucherUpdate.as_view(), name='event.voucher'),
@@ -382,6 +383,7 @@ urlpatterns = [
re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
re_path(r'^vouchers/bulk_add/mail_preview$', vouchers.VoucherBulkMailPreview.as_view(), name='event.vouchers.bulk.mail_preview'),
re_path(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'),
re_path(r'^vouchers/bulk_edit$', vouchers.VoucherBulkUpdateView.as_view(), name='event.vouchers.bulkedit'),
re_path(r'^vouchers/import/$', modelimport.VoucherImportView.as_view(), name='event.vouchers.import'),
re_path(r'^vouchers/import/(?P<file>[^/]+)/$', modelimport.VoucherProcessView.as_view(), name='event.vouchers.import.process'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
+5 -4
View File
@@ -28,7 +28,7 @@ from django.core.mail import get_connection
from django.shortcuts import redirect
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext, gettext_lazy as _
from django.views.generic import TemplateView
from pretix.base import email
@@ -216,13 +216,14 @@ class MailSettingsSetupView(TemplateView):
messages.error(request, _('The verification code was incorrect, please try again.'))
else:
self.request.session[session_key] = get_random_string(length=6, allowed_chars='1234567890')
sender_address = self.simple_form.cleaned_data.get('mail_from')
mail(
self.simple_form.cleaned_data.get('mail_from'),
_('Sender address verification'),
sender_address,
gettext('Confirm %(address)s as a sender address') % {'address': sender_address},
'pretixcontrol/email/email_setup.txt',
{
'code': self.request.session[session_key],
'address': self.simple_form.cleaned_data.get('mail_from'),
'address': sender_address,
'instance': settings.PRETIX_INSTANCE_NAME,
},
None,
+3 -1
View File
@@ -1032,7 +1032,9 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
def _send_invite(self, instance):
mail(
instance.email,
_('Account invitation'),
gettext('You\'ve been invited to join %(organizer)s') % {
'organizer': self.request.organizer.name,
},
'pretixcontrol/email/invitation.txt',
{
'instance': settings.PRETIX_INSTANCE_NAME,
+1
View File
@@ -537,6 +537,7 @@ class SubEventDetail(EventPermissionRequiredMixin, DetailView):
pcnt=Subquery(
OrderPosition.objects.filter(
subevent=self.object,
order_id=OuterRef("id"),
).values("subevent").annotate(c=Count("*")).values("c")
),
has_cancellation_request=Exists(CancellationRequest.objects.filter(order=OuterRef("pk"))),
+15
View File
@@ -975,6 +975,21 @@ def subevent_meta_values(request, organizer, event):
})
@event_permission_required('event.vouchers:read')
def voucher_tag_typeahead(request, **kwargs):
q = request.GET.get('q', '')
tags = request.event.vouchers.filter(
tag__isnull=False,
waitinglistentries__isnull=True,
).filter(
tag__icontains=q,
).values_list('tag', flat=True).distinct().order_by('tag')[:10]
return JsonResponse({
'results': [{'name': t} for t in tags]
})
def item_meta_values(request, organizer, event):
q = request.GET.get('q')
propname = request.GET.get('property')
+56 -16
View File
@@ -19,19 +19,23 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import hmac
import json
from contextlib import contextmanager
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import (
BACKEND_SESSION_KEY, get_user_model, load_backend, login,
BACKEND_SESSION_KEY, HASH_SESSION_KEY, get_user_model, load_backend, login,
logout,
)
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
@@ -218,11 +222,13 @@ class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthentica
def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
staff_session = request.user.get_active_staff_session(request.session.session_key)
self.request.user.log_action('pretix.control.auth.user.impersonated',
user=request.user,
data={
'other': self.kwargs.get("id"),
'other_email': self.object.email
'other_email': self.object.email,
'staff_session': staff_session.pk,
})
oldkey = request.session.session_key
@@ -230,7 +236,15 @@ class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthentica
hijacked = self.object
hijack_history = request.session.get("hijack_history", [])
hijack_history.append(request.user._meta.pk.value_to_string(hijacker))
hijack_history.append({
"user": request.user.pk,
# We include the auth_hash, because it is unguessable. So should an attacker gain an attack vector to
# modify hijack_history, they can't just insert or change a user that shouldn't be there. We HMAC it
# again, though, since we also do not want the auth_hash of the admin user to be in the session of an
# unprivileged user to contain the risk if there is some leak of session data.
"auth_hash": salted_hmac(key_salt=b"hijack-history-hash", value=request.session[HASH_SESSION_KEY],
algorithm="sha256", secret=settings.SECRET_KEY).hexdigest(),
})
backend = get_used_backend(request)
backend = f"{backend.__module__}.{backend.__class__.__name__}"
@@ -238,6 +252,12 @@ class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthentica
with signals.no_update_last_login(), keep_session_age(request.session):
login(request, hijacked, backend=backend)
request.session.save()
staff_session.logs.create(
method='(NOTE)',
url=f'Begin impersonating user #{hijacked.pk} (request session {oldkey[:8]} -> {request.session.session_key[:8]})',
)
request.session["hijack_history"] = hijack_history
signals.hijack_started.send(
@@ -254,13 +274,28 @@ class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthentica
class UserImpersonateStopView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
impersonated = request.user
hijs = request.session['hijacker_session']
staff_session_key = request.session['hijacker_session']
prev_session_key = request.session.session_key
hijack_history = request.session.get("hijack_history", [])
hijacked = request.user
user_pk = hijack_history.pop()
hijacker = get_object_or_404(get_user_model(), pk=user_pk)
prev_session = hijack_history.pop()
hijacker = get_object_or_404(get_user_model(), pk=prev_session["user"])
staff_session = hijacker.get_active_staff_session(staff_session_key)
if not staff_session:
raise PermissionDenied
expected_hash = salted_hmac(
key_salt=b"hijack-history-hash",
value=hijacker.get_session_auth_hash(),
algorithm="sha256",
secret=settings.SECRET_KEY
).hexdigest()
if not hmac.compare_digest(expected_hash, prev_session["auth_hash"]):
# Could be an attacker-controlled hijack history, but could also be e.g. a password change of the admin user
# that happened during the hijack session
logout(request)
return redirect_to_login(request.get_full_path())
backend = get_used_backend(request)
backend = f"{backend.__module__}.{backend.__class__.__name__}"
with signals.no_update_last_login(), keep_session_age(request.session):
@@ -275,17 +310,22 @@ class UserImpersonateStopView(LoginRequiredMixin, View):
hijacked=hijacked,
)
ss = request.user.get_active_staff_session(hijs)
if ss:
request.session.save()
ss.session_key = request.session.session_key
ss.save()
request.session.save()
staff_session.session_key = request.session.session_key
staff_session.save()
staff_session.logs.create(
method='(NOTE)',
url=f'Stop impersonating user #{hijacked.pk} (request session {prev_session_key[:8]}, '
f'staff session {staff_session_key[:8]} -> {request.session.session_key[:8]})',
)
request.user.log_action('pretix.control.auth.user.impersonate_stopped',
user=request.user,
data={
'other': impersonated.pk,
'other_email': impersonated.email
'other': hijacked.pk,
'other_email': hijacked.email,
'staff_session': staff_session.pk,
})
return redirect(reverse('control:index'))
+168 -27
View File
@@ -40,9 +40,11 @@ import bleach
from defusedcsv import csv
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.exceptions import (
BadRequest, PermissionDenied, ValidationError,
)
from django.db import connection, transaction
from django.db.models import Exists, OuterRef, Sum
from django.db.models import Count, Exists, OuterRef, Sum
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
JsonResponse,
@@ -55,7 +57,7 @@ from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views.generic import (
CreateView, ListView, TemplateView, UpdateView, View,
CreateView, FormView, ListView, TemplateView, UpdateView, View,
)
from django_scopes import scopes_disabled
@@ -70,7 +72,9 @@ from pretix.base.services.vouchers import vouchers_send
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncFormView
from pretix.control.forms.filter import VoucherFilterForm, VoucherTagFilterForm
from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm
from pretix.control.forms.vouchers import (
VoucherBulkEditForm, VoucherBulkForm, VoucherForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import voucher_form_class
from pretix.control.views import PaginationMixin
@@ -80,7 +84,37 @@ from pretix.helpers.models import modelcopy
from pretix.multidomain.urlreverse import eventreverse_absolute
class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
class VoucherQueryMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
return self.request.POST
return self.request.GET
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
def get_queryset(self):
qs = self.request.event.vouchers.exclude(
Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk')))
)
if 'voucher' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('voucher')
)
elif self.request.method == 'GET' or '__ALL' in self.request_data:
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
else:
raise BadRequest("No vouchers selected")
return qs
@cached_property
def filter_form(self):
return VoucherFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMixin, ListView):
model = Voucher
context_object_name = 'vouchers'
template_name = 'pretixcontrol/vouchers/index.html'
@@ -88,25 +122,15 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
def get_queryset(self):
qs = Voucher.annotate_budget_used(self.request.event.vouchers.exclude(
Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk')))
).select_related(
return Voucher.annotate_budget_used(super().get_queryset().select_related(
'item', 'variation', 'seat'
))
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
@cached_property
def filter_form(self):
return VoucherFilterForm(data=self.request.GET, event=self.request.event)
def get(self, request, *args, **kwargs):
if request.GET.get("download", "") == "yes":
return self._download_csv()
@@ -293,6 +317,12 @@ class VoucherUpdate(EventPermissionRequiredMixin, UpdateView):
f.disabled = True
return form
def get_form_kwargs(self):
return {
**super().get_form_kwargs(),
"event": self.request.event,
}
def get_object(self, queryset=None) -> VoucherForm:
url = resolve(self.request.path_info)
try:
@@ -603,26 +633,21 @@ class VoucherRNG(EventPermissionRequiredMixin, View):
})
class VoucherBulkAction(EventPermissionRequiredMixin, View):
class VoucherBulkAction(VoucherQueryMixin, EventPermissionRequiredMixin, View):
permission = 'event.vouchers:write'
@cached_property
def objects(self):
return self.request.event.vouchers.filter(
id__in=self.request.POST.getlist('voucher')
)
@transaction.atomic
def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'delete':
return render(request, 'pretixcontrol/vouchers/delete_bulk.html', {
'allowed': self.objects.filter(redeemed=0),
'forbidden': self.objects.exclude(redeemed=0),
'allowed': self.get_queryset().filter(redeemed=0),
'forbidden': self.get_queryset().exclude(redeemed=0),
})
elif request.POST.get('action') == 'delete_confirm':
log_entries = []
to_delete = []
for obj in self.objects:
to_update = []
for obj in self.get_queryset():
if obj.allow_delete():
log_entries.append(obj.log_action('pretix.voucher.deleted', user=self.request.user, save=False))
to_delete.append(obj.pk)
@@ -632,12 +657,14 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View):
'bulk': True
}, save=False))
obj.max_usages = min(obj.redeemed, obj.max_usages)
obj.save(update_fields=['max_usages'])
to_update.append(obj)
if to_delete:
CartPosition.objects.filter(addon_to__voucher_id__in=to_delete).delete()
CartPosition.objects.filter(voucher_id__in=to_delete).delete()
Voucher.objects.filter(pk__in=to_delete).delete()
if to_update:
Voucher.objects.bulk_update(to_update, ['max_usages'])
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(request, _('The selected vouchers have been deleted or disabled.'))
@@ -648,3 +675,117 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View):
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
class VoucherBulkUpdateView(VoucherQueryMixin, EventPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/vouchers/bulk_edit.html'
permission = 'event.vouchers:write'
context_object_name = 'vouchers'
form_class = VoucherBulkEditForm
def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by()
def get(self, request, *args, **kwargs):
return HttpResponse(status=405)
@cached_property
def is_submitted(self):
# Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
# called with POST method, even if just to pass the selection of objects to work on, so we want to modify
# that behavior
return '_bulk' in self.request.POST
def get_form_kwargs(self):
initial = {}
mixed_values = set()
qs = self.get_queryset().annotate()
fields = (
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment', 'max_usages',
'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'all_addons_included', 'all_bundles_included',
'budget',
)
for f in fields:
existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
if len(existing_values) == 1:
initial[f] = existing_values[0][f]
elif len(existing_values) > 1:
mixed_values.add(f)
if f == "max_usages":
initial[f] = 1
else:
initial[f] = None
existing_values = list(qs.order_by("item", "variation", "quota").values("item", "variation", "quota").annotate(c=Count('*')))
if len(existing_values) == 1:
i = existing_values[0]
if i["quota"]:
initial["itemvar"] = f'q-{i["quota"]}'
elif i["variation"]:
initial["itemvar"] = f'{i["item"]}-{i["variation"]}'
elif i["item"]:
initial["itemvar"] = f'{i["item"]}'
else:
initial["itemvar"] = None
elif len(existing_values) > 1:
mixed_values.add("itemvar")
initial["itemvar"] = None
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['prefix'] = 'bulkedit'
kwargs['initial'] = initial
kwargs['queryset'] = self.get_queryset()
kwargs['mixed_values'] = mixed_values
if not self.is_submitted:
kwargs['data'] = None
kwargs['files'] = None
return kwargs
def get_success_url(self):
return reverse('control:event.vouchers', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
def form_valid(self, form):
log_entries = []
# Main form
form.save()
data = {
k: v
for k, v in form.cleaned_data.items()
if k in form.changed_data
}
data['_raw_bulk_data'] = self.request.POST.dict()
for obj in self.get_queryset():
log_entries.append(
obj.log_action('pretix.voucher.changed', data=data, user=self.request.user, save=False)
)
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['vouchers'] = self.get_queryset()
ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
return ctx
@transaction.atomic
def post(self, request, *args, **kwargs):
form = self.get_form()
is_valid = (
self.is_submitted and
form.is_valid()
)
if is_valid:
return self.form_valid(form)
else:
if self.is_submitted:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.form_invalid(form)
+31 -6
View File
@@ -29,36 +29,61 @@ class EnvOrParserConfig:
self.cp = configparser
def _envkey(self, section, option):
section = re.sub('[^a-zA-Z0-9]', '_', section.upper())
option = re.sub('[^a-zA-Z0-9]', '_', option.upper())
return f'PRETIX_{section}_{option}'
section = re.sub("[^a-zA-Z0-9]", "_", section.upper())
option = re.sub("[^a-zA-Z0-9]", "_", option.upper())
return f"PRETIX_{section}_{option}"
def _file_envkey(self, section, option):
section = re.sub("[^a-zA-Z0-9]", "_", section.upper())
option = re.sub("[^a-zA-Z0-9]", "_", option.upper())
return f"FILE__PRETIX_{section}_{option}"
def get(self, section, option, *, raw=False, vars=None, fallback=_UNSET):
if self._file_envkey(section, option) in os.environ:
with open(os.environ[self._file_envkey(section, option)], "r") as f:
return f.read().strip()
if self._envkey(section, option) in os.environ:
return os.environ[self._envkey(section, option)]
return self.cp.get(section, option, raw=raw, vars=vars, fallback=fallback)
def getint(self, section, option, *, raw=False, vars=None, fallback=_UNSET):
if self._file_envkey(section, option) in os.environ:
with open(os.environ[self._file_envkey(section, option)], "r") as f:
return int(f.read().strip())
if self._envkey(section, option) in os.environ:
return int(os.environ[self._envkey(section, option)])
return self.cp.getint(section, option, raw=raw, vars=vars, fallback=fallback)
def getfloat(self, section, option, *, raw=False, vars=None, fallback=_UNSET):
if self._file_envkey(section, option) in os.environ:
with open(os.environ[self._file_envkey(section, option)], "r") as f:
return float(f.read().strip())
if self._envkey(section, option) in os.environ:
return float(os.environ[self._envkey(section, option)])
return self.cp.getfloat(section, option, raw=raw, vars=vars, fallback=fallback)
def getboolean(self, section, option, *, raw=False, vars=None, fallback=_UNSET):
if self._file_envkey(section, option) in os.environ:
with open(os.environ[self._file_envkey(section, option)], "r") as f:
return self.cp._convert_to_boolean(f.read().strip())
if self._envkey(section, option) in os.environ:
return self.cp._convert_to_boolean(os.environ[self._envkey(section, option)])
return self.cp.getboolean(section, option, raw=raw, vars=vars, fallback=fallback)
return self.cp._convert_to_boolean(
os.environ[self._envkey(section, option)]
)
return self.cp.getboolean(
section, option, raw=raw, vars=vars, fallback=fallback
)
def has_section(self, section):
if any(k.startswith(self._envkey(section, '')) for k in os.environ):
if any(k.startswith(self._file_envkey(section, "")) for k in os.environ):
return True
if any(k.startswith(self._envkey(section, "")) for k in os.environ):
return True
return self.cp.has_section(section)
def has_option(self, section, option):
if self._file_envkey(section, option) in os.environ:
return True
if self._envkey(section, option) in os.environ:
return True
return self.cp.has_option(section, option)
+11 -1
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import logging
import uuid
from django.core.signals import request_finished
from django.dispatch import receiver
@@ -46,6 +47,13 @@ class RequestIdFilter(logging.Filter):
return True
class SkipNotFoundFilter(logging.Filter):
# Drop the WARNING "Not Found: ..." records django.request emits for 404s
# We have different access logs for that
def filter(self, record):
return getattr(record, 'status_code', None) != 404
class RequestIdMiddleware:
def __init__(self, get_response):
self.get_response = get_response
@@ -58,7 +66,9 @@ class RequestIdMiddleware:
import sentry_sdk
sentry_sdk.set_tag("request_id", request.request_id)
else:
local.request_id = request.request_id = None
# Web server did not pass a request ID, we still generate one to correlate between django logs and
# celery logs
local.request_id = request.request_id = str(uuid.uuid4())
return self.get_response(request)
+2 -1
View File
@@ -151,13 +151,14 @@ def monkeypatch_urllib3_ssrf_protection():
if not getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
if ip_addr.is_multicast:
raise HTTPError(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise HTTPError(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise HTTPError(f"Request to private address {sa[0]} blocked")
if ip_addr in _cgnat_net:
if check_ip4 in _cgnat_net:
raise HTTPError(f"Request to RFC 6598 address {sa[0]} blocked")
sock = None
+14 -1
View File
@@ -25,6 +25,19 @@ import text_unidecode
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
EPC_QR_ALLOWED_CHARS = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789/-?:().,'+ "
)
def epc_qr_field(value):
return ''.join(
char for char in text_unidecode.unidecode(str(value or ''))
if char in EPC_QR_ALLOWED_CHARS
)
def dotdecimal(value):
return str(value).replace(",", ".")
@@ -77,7 +90,7 @@ def euro_epc_qr(
return {
"id": "girocode",
"label": "EPC-QR",
"qr_data": "\n".join(text_unidecode.unidecode(str(d or '')) for d in [
"qr_data": "\n".join(epc_qr_field(d) for d in [
"BCD", # Service Tag: BCD
"002", # Version: V2
"2", # Character set: ISO 8859-1
+8 -3
View File
@@ -27,6 +27,7 @@ from django.conf import settings
from django.contrib.auth import login as auth_login
from django.contrib.gis import geoip2
from django.core.cache import cache
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
@@ -169,11 +170,15 @@ def handle_login_source(user, request):
with language(user.locale):
mail(
user.email,
_('Login from new source detected'),
_('New sign-in to your account'),
'pretixcontrol/email/login_notice.txt',
{
'source': src,
'country': Country(str(country)).name if country else _('Unknown country'),
'when': date_format(src.last_seen, 'DATETIME_FORMAT'),
'agent': src.agent_type,
'os': src.os_type,
# ua-parser returns "Other" for unidentified desktop devices.
'device': src.device_type if src.device_type and src.device_type != 'Other' else None,
'country': Country(str(country)).name if country else None,
'instance': settings.PRETIX_INSTANCE_NAME,
'url': mainreverse_absolute('control:user.settings')
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -74,6 +74,7 @@ Bokmål
Boleto
Branding-Informationen
Browsereinstellungen
Browserfenster
BSD-Lizenz
bspw
Bundles
@@ -134,6 +135,7 @@ Einlasskontrolle
Einmalpasswörter
einzuchecken
email
E-Mail-App
E-Mail-Renderer
Enterprise-Lizenz
Enterprise-Lizenzen
@@ -152,6 +154,7 @@ erstmalig
etc
EU-USt-ID-Nr
Event
Event-Absage
Event-Eigenschaft
Eventeingang
Event-Erstellung
@@ -206,6 +209,7 @@ innenname
innennamen
innergemeinschaftliche
Innergemeinschaftlicher
Instagram
Installations-ID
Integrationen
invalidieren
@@ -227,6 +231,7 @@ Linktext
lit
Log-ID
Logindaten
Loginvorgangs
Macau
MapQuest-API-Key
max
@@ -450,6 +455,7 @@ Strong
Swish
systemweiten
Tab
Tabs
tag
Teammitglied
Teamname
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -74,6 +74,7 @@ Bokmål
Boleto
Branding-Informationen
Browsereinstellungen
Browserfenster
BSD-Lizenz
bspw
Bundles
@@ -134,6 +135,7 @@ Einlasskontrolle
Einmalpasswörter
einzuchecken
email
E-Mail-App
E-Mail-Renderer
Enterprise-Lizenz
Enterprise-Lizenzen
@@ -152,6 +154,7 @@ erstmalig
etc
EU-USt-ID-Nr
Event
Event-Absage
Event-Eigenschaft
Eventeingang
Event-Erstellung
@@ -206,6 +209,7 @@ innenname
innennamen
innergemeinschaftliche
Innergemeinschaftlicher
Instagram
Installations-ID
Integrationen
invalidieren
@@ -227,6 +231,7 @@ Linktext
lit
Log-ID
Logindaten
Loginvorgangs
Macau
MapQuest-API-Key
max
@@ -450,6 +455,7 @@ Strong
Swish
systemweiten
Tab
Tabs
tag
Teammitglied
Teamname
+8885 -9924
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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