Compare commits

..
Author SHA1 Message Date
Mira Weller 135dcf42c9 Refactor fragment_product_list.html 2024-05-03 15:51:58 +02:00
Mira Weller 1d95c1920d Allow adding products from multiple subevents to the cart at once 2024-05-03 15:49:47 +02:00
Raphael Michel 806124304a Voucher get code validation 2024-05-03 14:34:51 +02:00
Serhii HorichenkoandRaphael Michel 0d57673a47 Translations: Update Ukrainian
Currently translated at 62.9% (3529 of 5607 strings)

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

powered by weblate
2024-05-03 09:52:42 +02:00
Raphael MichelandGitHub 166b5e4f3b Change default password hash to argon2id (#4121)
* Change default password hash to argon2id

* Install argon2
2024-05-02 18:22:02 +02:00
541b8f5bd6 Discounts: Fix edge case in computation (#4126)
* Add new test case for discounts:

Two discounts:
- "For every 1 item1, you get three item2 for 10 % off."
- "For every 1 item1, you get five item3 for 10 % off."
Cart: 2x item1, 2x item2, 6x item3
Expected result: 2x item1 full price, 2x item2 discounted, 5x item3 discounted, 1x item3 full price

* Fix discount calculation bug

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

Co-authored-by: Raphael Michel <michel@rami.io>

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

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-05-02 18:21:56 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d2b96b2425 Bump @babel/core from 7.24.3 to 7.24.5 in /src/pretix/static/npm_dir (#4125)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.24.3 to 7.24.5.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.24.5/packages/babel-core)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-02 18:21:30 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
04d4c4f8f1 Bump @babel/preset-env from 7.24.3 to 7.24.5 in /src/pretix/static/npm_dir (#4124)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.24.3 to 7.24.5.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.24.5/packages/babel-preset-env)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-02 16:31:55 +02:00
Raphaël DeuxandRaphael Michel f4da94cbcd Translations: Update French
Currently translated at 96.7% (5424 of 5607 strings)

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

powered by weblate
2024-05-02 16:31:45 +02:00
Serhii HorichenkoandRaphael Michel 97e3d5387f Translations: Update Ukrainian
Currently translated at 98.2% (225 of 229 strings)

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

powered by weblate
2024-05-02 16:31:45 +02:00
Serhii HorichenkoandRaphael Michel 8fc07523a9 Translations: Update Ukrainian
Currently translated at 62.8% (3526 of 5607 strings)

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

powered by weblate
2024-05-02 16:31:45 +02:00
NikolaiandRaphael Michel f18b0ae187 Translations: Update Danish
Currently translated at 31.1% (1744 of 5607 strings)

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

powered by weblate
2024-05-02 16:31:45 +02:00
Raphael Michel f7e16f56ac Revert "Install argon2"
This reverts commit 3f4e869cea.
2024-05-02 16:27:46 +02:00
Raphael Michel 3f4e869cea Install argon2 2024-05-02 16:27:37 +02:00
Martin Gross 8c2a1d58f4 Templates: Add Herma 4515 (Acetatesilk) to repository 2024-05-02 13:57:46 +02:00
Raphael Michel 0b05eb34f4 Fix style of buttons in alerts 2024-04-30 12:44:41 +02:00
Raphael Michel be48c5f94c Bump version to 2024.5.0.dev0 2024-04-30 11:11:13 +02:00
Raphael MichelandGitHub cebb6d3b43 Introduce locking to prevent duplicate invoices (Z#23150548) (#4067)
* Introduce locking to prevent duplicate invoices

This is not a perfect solution as it does not handle all code paths to
create invoices, but it handles all that seem likely to be triggered
concurrently

* Review note
2024-04-30 10:43:13 +02:00
0de96ed066 Add links to invalid inputs on error alert (Z#23149061) (#4114)
* Add links to invalid inputs on error alert

* add errors in sub-forms to message, fix issues with multi-checkboxes labels and inputs

* add scrollTarget.scrollIntoView

* add missing semi-colon

* improve comment

* add style for links in alert-danger

* fix link color for all alert-boxes

* Update src/pretix/static/pretixcontrol/js/ui/main.js

---------

Co-authored-by: Raphael Michel <michel@rami.io>
2024-04-30 10:18:32 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a9d506b1fa Update pytest-xdist requirement from ==3.5.* to ==3.6.* (#4118)
Updates the requirements on [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest-xdist/releases)
- [Changelog](https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-xdist/compare/v3.5.0...v3.6.1)

---
updated-dependencies:
- dependency-name: pytest-xdist
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-30 10:14:54 +02:00
Serhii HorichenkoandRaphael Michel 7a01057429 Translations: Update Ukrainian
Currently translated at 89.9% (206 of 229 strings)

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

powered by weblate
2024-04-30 10:14:00 +02:00
Serhii HorichenkoandRaphael Michel 64e1a602d6 Translations: Update Ukrainian
Currently translated at 62.9% (3527 of 5607 strings)

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

powered by weblate
2024-04-30 10:14:00 +02:00
NikolaiandRaphael Michel fe060c387a Translations: Update Danish
Currently translated at 31.0% (1741 of 5607 strings)

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

powered by weblate
2024-04-30 10:14:00 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1dba4c7cc9 Update pytest requirement from ==8.1.* to ==8.2.* (#4119)
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.1.0.dev0...8.2.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-29 21:02:07 +02:00
Richard SchreiberandGitHub 20b2a3d2aa Control: add link to orders for each subevent in list of subevents (Z#23129436) (#3566) 2024-04-29 18:44:42 +02:00
044f0c5480 Fix N+1 query in event calendar found by sentry (#4104)
* Fix N+1 query in event calendar found by sentry

* isort

---------

Co-authored-by: Mira Weller <weller@rami.io>
2024-04-29 18:41:50 +02:00
4d394f9e8a Answer file export: Allow to filter by subevent (Z#23150581) (#4066)
* Answer file export: Allow to filter by subevent (Z#23150581)

* Update src/pretix/base/exporters/answers.py

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

* Fix isort

---------

Co-authored-by: Mira <weller@rami.io>
2024-04-29 18:12:58 +02:00
Raphael MichelandGitHub 247c4c6c9c Do not remove unavailable addons when changing order (Z#23150855) (#4086) 2024-04-29 18:11:20 +02:00
Raphael MichelandGitHub 11a038feb3 Allow secret generators to access order datetime (#4110) 2024-04-26 15:09:01 +02:00
Raphael MichelandGitHub 9d57ea8534 API: Do not write log entry for events when no changes are made (#4090) 2024-04-26 13:56:46 +02:00
Raphael Michel 189c77207f Bump version to 2024.4.0 2024-04-26 13:53:49 +02:00
Raphael MichelandRaphael Michel 3422003a9c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5607 of 5607 strings)

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

powered by weblate
2024-04-26 13:53:17 +02:00
Raphael MichelandRaphael Michel 8da38ba99d Translations: Update German
Currently translated at 100.0% (5607 of 5607 strings)

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

powered by weblate
2024-04-26 13:53:17 +02:00
Raphael Michel fc05208b92 Docs: Add note on Swissbit TSE to Android support policy 2024-04-26 13:36:49 +02:00
Raphael Michel b163109c56 Docs: ADd undocumented metrics 2024-04-26 12:28:38 +02:00
Raphael Michel 3ba818336e Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-04-26 11:26:25 +02:00
Raphael Michel 8aecf4f98f Voucher import: Validate code not empty 2024-04-26 11:25:49 +02:00
Martin Gross 42f3ca9661 SecHelper: Fix typo in logging statement 2024-04-24 18:13:27 +02:00
Raphael Michel f7b405b210 Voucher import: Do not allow to keep min/max usages empty 2024-04-24 14:20:54 +02:00
Raphael Michel 11a6390cfc Voucher import: Fix quota name check 2024-04-24 10:28:10 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Raphael Michel
239a7746df Update djangorestframework requirement from ==3.14.* to ==3.15.* (#4001)
Updates the requirements on [djangorestframework](https://github.com/encode/django-rest-framework) to permit the latest version.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.14.0...3.15.0)

---
updated-dependencies:
- dependency-name: djangorestframework
  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@rami.io>
2024-04-23 16:56:02 +02:00
03701eaa82 Docs: Add explicit user change to installation guide (#3972)
* Update manual_smallscale.rst

(I propose to) 
add

- sudo su pretix ; and
- deactivate

commands to the documentation,n

* Update doc/admin/installation/manual_smallscale.rst

* Update doc/admin/installation/manual_smallscale.rst

* Update doc/admin/installation/manual_smallscale.rst

---------

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2024-04-23 15:18:33 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
356f215d8e Update celery requirement from ==5.3.* to ==5.4.* (#4091)
Updates the requirements on [celery](https://github.com/celery/celery) to permit the latest version.
- [Release notes](https://github.com/celery/celery/releases)
- [Changelog](https://github.com/celery/celery/blob/main/Changelog.rst)
- [Commits](https://github.com/celery/celery/compare/v5.3.0a1...v5.4.0)

---
updated-dependencies:
- dependency-name: celery
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 15:11:40 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7962c4e380 Update django-otp requirement from ==1.4.* to ==1.5.* (#4083)
Updates the requirements on [django-otp](https://github.com/django-otp/django-otp) to permit the latest version.
- [Changelog](https://github.com/django-otp/django-otp/blob/master/CHANGES.rst)
- [Commits](https://github.com/django-otp/django-otp/compare/v1.4.0...v1.5.0)

---
updated-dependencies:
- dependency-name: django-otp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 15:11:31 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a59711ed32 Update django-statici18n requirement from ==2.4.* to ==2.5.* (#4106)
Updates the requirements on [django-statici18n](https://github.com/zyegfryed/django-statici18n) to permit the latest version.
- [Changelog](https://github.com/zyegfryed/django-statici18n/blob/main/docs/changelog.rst)
- [Commits](https://github.com/zyegfryed/django-statici18n/compare/v2.4.0...v2.5.0)

---
updated-dependencies:
- dependency-name: django-statici18n
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 15:11:18 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
49370a5e08 Update reportlab requirement from ==4.1.* to ==4.2.* (#4092)
Updates the requirements on [reportlab](https://www.reportlab.com/) to permit the latest version.

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 15:03:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
980aec7326 Update fakeredis requirement from ==2.21.* to ==2.22.* (#4105)
Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.21.0...v2.22.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 15:03:09 +02:00
Raphael MichelandRaphael Michel 44294110fe Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5606 of 5606 strings)

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

powered by weblate
2024-04-23 14:59:03 +02:00
Raphael MichelandRaphael Michel ce1078a783 Translations: Update German
Currently translated at 100.0% (5606 of 5606 strings)

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

powered by weblate
2024-04-23 14:59:03 +02:00
Raphael Michel 0e0cede0ee Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-04-23 14:41:20 +02:00
5c833cd493 Add special input widgets for markdown fields (#1577)
* Add markdown text input widgets

* Update src/pretix/static/pretixcontrol/scss/_forms.scss

* Improvements

---------

Co-authored-by: Raphael Michel <michel@rami.io>
2024-04-23 14:40:30 +02:00
Raphael MichelandGitHub 64d6a34039 Ensure correct language of invoice attachment file name (Z#23150936) (#4094) 2024-04-23 14:07:30 +02:00
MiraandRaphael Michel cf380069b4 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5598 of 5598 strings)

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

powered by weblate
2024-04-23 14:07:17 +02:00
MiraandRaphael Michel 48168a4c68 Translations: Update German
Currently translated at 100.0% (5598 of 5598 strings)

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

powered by weblate
2024-04-23 14:07:17 +02:00
Raphael Michel 6482fe79b0 Add force to log entry in extend_order() 2024-04-22 21:34:46 +02:00
Raphael MichelandGitHub 0f696f42f6 Do not allow currency codes that to not represent money (#4056)
* Do not allow currency codes that to not represent money

* Rebase migration

* Fix blacklist
2024-04-22 16:22:30 +02:00
Martin Gross 79d59553d7 POS: Do not fail when rendering ZVT-transactions without a cardName or cardType 2024-04-22 13:52:18 +02:00
Raphael Michel cc903c39f0 Docs: Fix copy-paste error in COEP docs 2024-04-22 13:18:34 +02:00
Raphael MichelandGitHub b6a42ac8d2 Widget: Document and improve situation around COEP (Z#23149909) (#4051) 2024-04-22 13:15:33 +02:00
Raphael MichelandGitHub 5f5001edb5 File upload: Fix thumbnailing of current image (Z#23151293) (#4098) 2024-04-22 13:14:28 +02:00
Raphael MichelandGitHub fb403dad88 Add comment field to subevents (Z#23148472) (#4099)
* Add comment field to subevents (Z#23148472)

* Review fix
2024-04-22 12:56:29 +02:00
Christiaan de Die le ClercqandRaphael Michel a73c8f580d Translations: Update Dutch
Currently translated at 88.1% (4937 of 5598 strings)

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

powered by weblate
2024-04-22 11:38:27 +02:00
Raphael MichelandGitHub f490c89e98 Seating plans: Do not allow GUIDs starting or ending with spaces (#4093) 2024-04-22 11:31:34 +02:00
Raphael Michel 159658ae46 Fix typo in attribute name 2024-04-19 17:34:38 +02:00
Christiaan de Die le ClercqandGitHub 595aff0579 Add a community install guide page (#4096) 2024-04-19 15:08:47 +02:00
Raphael MichelandGitHub b2842ec3a0 Checkin: Allow to use presence state in rules (#4061) 2024-04-18 13:15:31 +02:00
Raphael MichelandGitHub f09f07ec7c Add Android Version Support Policy (#4074) 2024-04-18 11:35:28 +02:00
Richard SchreiberandGitHub cff073f0d6 Fix: show human-readable country name in profile description for custom country-code questions (#4078) 2024-04-18 11:08:21 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
eb9d0c6cf9 Update sphinx requirement from ==7.2.* to ==7.3.* (#4088)
Updates the requirements on [sphinx](https://github.com/sphinx-doc/sphinx) to permit the latest version.
- [Release notes](https://github.com/sphinx-doc/sphinx/releases)
- [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst)
- [Commits](https://github.com/sphinx-doc/sphinx/compare/v7.2.0...v7.3.5)

---
updated-dependencies:
- dependency-name: sphinx
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-18 11:06:52 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e263946c3f Update django-bootstrap3 requirement from ==23.6.* to ==24.2 (#4087)
Updates the requirements on [django-bootstrap3](https://github.com/zostera/django-bootstrap3) to permit the latest version.
- [Changelog](https://github.com/zostera/django-bootstrap3/blob/main/CHANGELOG.md)
- [Commits](https://github.com/zostera/django-bootstrap3/compare/v23.6...v24.2)

---
updated-dependencies:
- dependency-name: django-bootstrap3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-18 11:06:46 +02:00
ShiikoandRaphael Michel 7ee957cff0 Translations: Update Chinese (Simplified)
Currently translated at 52.5% (2940 of 5598 strings)

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

powered by weblate
2024-04-17 17:18:31 +02:00
Raphael Michel ac2fe4b62d Fix crash in placeholder samples (PRETIXEU-9YP) 2024-04-17 16:37:40 +02:00
Raphael Michel 577e276df3 Fix voucher import validation issue (PRETIXEU-9YF) 2024-04-17 15:42:05 +02:00
Richard Schreiber 11956a8f4d fix isort 2024-04-15 16:25:25 +02:00
Raphael Michel d0c58713c4 Badge/ticket export: Allow to sort by company 2024-04-15 10:55:40 +02:00
Raphael Michel 46da0bda61 Placeholders: Use dynamic sample for name_for_salutation 2024-04-15 10:55:40 +02:00
Raphael MichelandGitHub 009e3a6d36 Respect Order.valid_if_pending for mail_attach_ical_paid_only (#4065) 2024-04-12 15:00:00 +02:00
Martin GrossandGitHub c01855270b Approval Process: Attach ical to approval-messages if properly configured (#4064) 2024-04-12 11:18:21 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2df1585b71 Update sentry-sdk requirement from ==1.44.* to ==1.45.* (#4058)
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/1.44.0...1.45.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-11 13:33:01 +02:00
Raphael Michel bf48ae567f NREI export: Fix parsing of stripe data 2024-04-11 09:27:55 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5a72c72d18 Update django-otp requirement from ==1.3.* to ==1.4.* (#4057)
Updates the requirements on [django-otp](https://github.com/django-otp/django-otp) to permit the latest version.
- [Changelog](https://github.com/django-otp/django-otp/blob/master/CHANGES.rst)
- [Commits](https://github.com/django-otp/django-otp/compare/v1.3.0...v1.4.0)

---
updated-dependencies:
- dependency-name: django-otp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-10 09:56:40 +02:00
Raphael Michel ac02f3b417 API: Fix crash in order creation (PRETIXEU-9XS) 2024-04-10 09:40:35 +02:00
Martin GrossandGitHub 58add74b3a Stripe: Add PayPal (#4049) (Z#23123667) 2024-04-09 10:06:58 +02:00
Raphael MichelandGitHub 0067c3537d Fix invalid orders being created in a complex situation (#4054)
This was a bug that took days to find. The story goes like this: A cart
is created with four positions that each include four bundled positions.
A discount is applied, changing the price of *one* of the four top-level
positions to a reduced value. The list of position IDs gets passed to
`perform_order()`, which later passes it on to `transform_cart_positions()`.
`transform_cart_positions()`, however, receives the positions in an order
that has the first-level product *after* the bundled products that
belong to it. Therefore, it can't properly assign the parent-child
relationship between the positions.

The main reason is that cart positions are processed in "database order"
in a number of places, i.e. we make `SELECT` queries without an explicit
`ORDER BY` statement, leading the database to respond in unspecified
order. This is the case for `get_cart()` and hence for `CartMixin.positions`,
and hence for the list of position IDs that is passed to `perform_order()`
and hence for the order in which discounts are processed.

Therefore, if this "databse order" of the cart positions changes, the
discount compuation in `_check_positions()` might make a different choice
of *which* cart position should receive the discount than the CartManager
originally did. That's not nice, but most customers would not even
notice that a different one of their four (otherwise identical) tickets
is now discounted than the cart originally showed.

This leads to `_check_positions()` changing the price on two of the
cart positions. However, it only changes the price on the copy of
the CartPosition object that is directly part of the positions array,
while the `addon_to` attribute of its bundled positions contain a
*different* representation of the same cart position, that is not
refreshed to have the updated price now in the database.

This causes the `CartPosition.sort_key` of the bundled products to be
significantly different from the one of their parent products, which can
cause `transform_cart_positions()` to try to insert them before their
respective parent product, which is how the bug leads to the nasty end
result.

Now, I'm still not sure why this has happened *now* for the first time,
but I suspect it *might* even have something to do with our operations
team tuning our autovacuum parameters on our production installation,
which might make it *more likely* that newly created cart positions are
arbitrarily  stored on PostgreSQL disk pages in a different order than
they were inserted than before.

This commit now fixes the bug now in two ways, each of which would be
sufficient to fix it for now, but together they make it hopefully more
stable in the future:

- `perform_order` no longer respects the order of the position IDs it
  gets passed in, but instead uses the order last displayed in the cart.
  Additionally, both `CartManager` and `_check_positions()` now sort
  positions by their `pk` value before applying discounts to ensure
  consistent choice of which position is discounted (using  `sort_key`
  here does not make much sense since it includes sorting by price,
  which is about to change).

- `_check_positions()` makes sure that after its completion, only one
  copy of the same `CartPosition` is in use that has the current price.

Additionally, this commit makes sure `sort_key` cache is cleared after
e.g. a price change.

It was hard to write a regression test, since "database order" is, by
definition, unreliable, but I tried my best.
2024-04-08 16:55:54 +02:00
Raphael Michel 64ae1d08a6 Docs: Fix incorrect API response 2024-04-05 17:28:44 +02:00
Raphael Michel ca25c3c81e Add logging for special bug case (Z#23149646) 2024-04-04 18:13:54 +02:00
Raphael Michel abbe9ec897 Order creation: Fail loudly on invalid addon-to relationship 2024-04-03 17:21:47 +02:00
Raphael MichelandGitHub a7735d5d9e API: Allow request_valid_from in the past (#4048) 2024-04-03 17:21:25 +02:00
Raphael MichelandRaphael Michel 174c81a22b Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5598 of 5598 strings)

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

powered by weblate
2024-04-03 13:15:57 +02:00
Raphael MichelandRaphael Michel 38c6294ede Translations: Update German
Currently translated at 100.0% (5598 of 5598 strings)

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

powered by weblate
2024-04-03 13:15:57 +02:00
Raphael Michel 217ae90642 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-04-03 12:43:52 +02:00
Raphael Michel 0c998ca884 Remove special handling of translations in pretix/helpers 2024-04-03 12:43:20 +02:00
Raphael MichelandRaphael Michel b1691f867d Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5594 of 5594 strings)

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

powered by weblate
2024-04-03 11:43:39 +02:00
Raphael MichelandRaphael Michel 72e451b27b Translations: Update German
Currently translated at 100.0% (5594 of 5594 strings)

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

powered by weblate
2024-04-03 11:43:39 +02:00
Raphael Michel 8124ced6c1 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-04-03 11:22:25 +02:00
Raphael MichelandGitHub a3139944f6 Send notifications about login with new client or country (#4032)
* Send notifications about login with new client or country

* Rebase migration

* Remove immediately

* Fix isort

* Text update
2024-04-03 11:19:20 +02:00
Felix SchäferandGitHub 48493c517b Add database.disable_server_side_cursors option (#4016) 2024-04-03 10:16:48 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
535a29bf4b Update css-inline requirement from ==0.13.* to ==0.14.* (#4043)
Updates the requirements on [css-inline](https://github.com/Stranger6667/css-inline) to permit the latest version.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/c-v0.13.0...c-v0.14.0)

---
updated-dependencies:
- dependency-name: css-inline
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-03 10:16:13 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9d415f5179 Update pillow requirement from ==10.2.* to ==10.3.* (#4044)
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/10.2.0...10.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-03 10:16:03 +02:00
990e9da21d Generalize import process from orders to more models (#4002)
* Generalize import process from orders to more models

* Add voucher import

* Model import: Guess assignments of based on column headers

* Fix lock_seats being pointless

* Update docs

* Update doc/development/api/import.rst

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update src/pretix/base/modelimport_vouchers.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-04-03 10:15:30 +02:00
4afb7a4976 Allow admins to generate emergency 2FA tokens (#4035)
* Allow admins to generate emergency 2FA tokens

* Update src/pretix/control/views/users.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-04-03 10:15:17 +02:00
Raphael MichelandRaphael Michel 22e5579ed1 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5585 of 5585 strings)

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

powered by weblate
2024-04-03 10:14:56 +02:00
Raphael MichelandRaphael Michel 79cd84e243 Translations: Update German
Currently translated at 100.0% (5585 of 5585 strings)

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

powered by weblate
2024-04-03 10:14:56 +02:00
Raphael MichelandRaphael Michel fad4b8846c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5585 of 5585 strings)

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

powered by weblate
2024-04-03 10:14:56 +02:00
Raphael MichelandRaphael Michel 0f4790afd8 Translations: Update German
Currently translated at 100.0% (5585 of 5585 strings)

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

powered by weblate
2024-04-03 10:14:56 +02:00
Raphael Michel 2068a5ac29 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-04-02 17:53:26 +02:00
Raphael MichelandGitHub 440c97061c Fix duplicate key on SQLite (#4038)
* Fix duplicate key on SQLite

* Update migration
2024-04-02 17:37:37 +02:00
Raphael MichelandRaphael Michel 3b6d0c4341 Translations: Add Slovak 2024-04-02 17:28:11 +02:00
Raphael MichelandRaphael Michel 06ac4b0250 Translations: Add Slovak 2024-04-02 17:28:11 +02:00
Raphael MichelandGitHub a233b92f6f Add disable date of waiting list to event timeline (#4036) 2024-04-02 17:15:41 +02:00
Raphael MichelandGitHub 4ea4189e6d Allow team admins to require two-factor authentication (#4034)
* Allow team admins to require two-factor authentication

* Add API tests

* Improve logic

* ADd button tooltip
2024-04-02 17:15:16 +02:00
MiraandGitHub 50838b9cea Update github action versions (#4033) 2024-04-02 13:53:09 +02:00
Raphael MichelandGitHub c68ee56d51 Log discarding a valid session for suspicious reasons (#4025) 2024-04-02 13:52:30 +02:00
ThatthepandRaphael Michel 5c0587c30e Translations: Update Thai
Currently translated at 0.6% (36 of 5574 strings)

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

powered by weblate
2024-04-02 11:45:11 +02:00
Raphael Michel f3f42a8a42 Login: Add logging for incorrect JS hostnames 2024-04-02 11:34:43 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
20d0a9a0ed Update django-countries requirement from ==7.5.* to ==7.6.* (#4031)
Updates the requirements on [django-countries](https://github.com/SmileyChris/django-countries) to permit the latest version.
- [Changelog](https://github.com/SmileyChris/django-countries/blob/main/CHANGES.rst)
- [Commits](https://github.com/SmileyChris/django-countries/compare/v7.5...v7.6.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 11:07:51 +02:00
cda8144ff0 Enforce uniqueness of order codes and ticket secrets (#3988)
* Enforce uniqueness of order codes and ticket secrets

* Fix test cases which created orders with identical codes

---------

Co-authored-by: Mira Weller <weller@rami.io>
2024-04-02 11:07:40 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
43e8875c1e Update sentry-sdk requirement from ==1.42.* to ==1.44.* (#4021)
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/1.42.0...1.44.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:26:37 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28c142b2ed Update pytest-mock requirement from ==3.12.* to ==3.14.* (#4009)
Updates the requirements on [pytest-mock](https://github.com/pytest-dev/pytest-mock) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest-mock/releases)
- [Changelog](https://github.com/pytest-dev/pytest-mock/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.12.0...v3.14.0)

---
updated-dependencies:
- dependency-name: pytest-mock
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:14:24 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
46203fd8ba Bump @babel/core from 7.24.0 to 7.24.3 in /src/pretix/static/npm_dir (#4027)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.24.0 to 7.24.3.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.24.3/packages/babel-core)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:13:39 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
52e45c37df Update webauthn requirement from ==2.0.* to ==2.1.* (#4022)
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.0.0...v2.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:13:17 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d1580dca2c Bump django-filter from 24.1 to 24.2 (#4017)
Bumps [django-filter](https://github.com/carltongibson/django-filter) from 24.1 to 24.2.
- [Release notes](https://github.com/carltongibson/django-filter/releases)
- [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst)
- [Commits](https://github.com/carltongibson/django-filter/compare/24.1...24.2)

---
updated-dependencies:
- dependency-name: django-filter
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:13:00 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cd9e672871 Bump pycparser from 2.21 to 2.22 (#4029)
Bumps [pycparser](https://github.com/eliben/pycparser) from 2.21 to 2.22.
- [Release notes](https://github.com/eliben/pycparser/releases)
- [Changelog](https://github.com/eliben/pycparser/blob/main/CHANGES)
- [Commits](https://github.com/eliben/pycparser/compare/release_v2.21...release_v2.22)

---
updated-dependencies:
- dependency-name: pycparser
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:10:42 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
427f508627 Bump @babel/preset-env from 7.24.0 to 7.24.3 in /src/pretix/static/npm_dir (#4028)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.24.0 to 7.24.3.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.24.3/packages/babel-preset-env)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-02 10:10:01 +02:00
Raphael Michel 887d06a485 Add .watchmanconfig 2024-03-31 23:14:57 +02:00
fb49046ac1 Log and count user logins (#4020)
* Log and count user logins

* Allow metrics without label

---------

Co-authored-by: Mira Weller <weller@rami.io>
2024-03-28 17:18:51 +01:00
Martin Gross ce826e50f7 PDF: Check for allowed font name before adding modifiers to the name 2024-03-27 12:54:35 +01:00
Raphael Michel d866c6954d Bump to 2024.4.0.dev0 2024-03-27 12:15:31 +01:00
241 changed files with 117214 additions and 71740 deletions
+3 -3
View File
@@ -26,12 +26,12 @@ jobs:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+3 -3
View File
@@ -25,12 +25,12 @@ jobs:
name: Spellcheck
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+6 -6
View File
@@ -23,12 +23,12 @@ jobs:
runs-on: ubuntu-22.04
name: Check gettext syntax
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -48,12 +48,12 @@ jobs:
runs-on: ubuntu-22.04
name: Spellcheck
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+8 -8
View File
@@ -23,12 +23,12 @@ jobs:
name: isort
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -43,12 +43,12 @@ jobs:
name: flake8
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -63,9 +63,9 @@ jobs:
name: licenseheaders
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install Dependencies
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
- database: sqlite
python-version: "3.10"
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- uses: harmon758/postgresql-action@v1
with:
postgresql version: '15'
@@ -41,10 +41,10 @@ jobs:
postgresql password: 'postgres'
if: matrix.database == 'postgres'
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v1
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+9 -2
View File
@@ -97,8 +97,9 @@ Example::
Defaults to ``off``.
``obligatory_2fa``
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
Defaults to ``False``
Enables or disables obligatory usage of two-factor authentication for users of the pretix backend.
Can be ``True`` to make two-factor authentication obligatory for all users or ``staff`` to make it only
obligatory to users with admin permissions. Defaults to ``False``.
``trust_x_forwarded_for``
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
@@ -157,6 +158,7 @@ Example::
host=localhost
port=3306
advisory_lock_index=1
disable_server_side_cursors=0
sslmode=require
sslrootcert=/etc/pretix/postgresql-ca.crt
sslcert=/etc/pretix/postgresql-client-crt.crt
@@ -177,6 +179,11 @@ Example::
and are not scoped to a specific database. If you run multiple pretix applications with the same PostgreSQL server,
you should set separate values for this setting (integers up to 256).
``disable_server_side_cursors``
On PostgreSQL pretix might use server side cursors for certain operations. This is generally fine but will break in
specific circumstances, for example when connecting to PostgreSQL through a PGBouncer configured with a transaction
pool mode. Off by default (i.e. by default server side cursors will be used).
``sslmode``, ``sslrootcert``
Connection TLS details for the PostgreSQL database connection. Possible values of ``sslmode`` are ``disable``, ``allow``, ``prefer``, ``require``, ``verify-ca``, and ``verify-full``. ``sslrootcert`` should be the accessible path of the ca certificate. Both values are empty by default.
+13
View File
@@ -0,0 +1,13 @@
.. highlight:: none
.. _`community`:
Community install guides
================================
.. warning:: The guides are maintained by the community and not by the pretix core team. If you encounter any issues with the guides, please report them to the maintainers of the guides. The pretix core team can not provide support for installs using these guides.
Kubernetes
------------
- Helm Chart by techwolf12 - A Helm chart for deploying pretix on Kubernetes. The chart documentation is available on `ArtifactHub <https://artifacthub.io/packages/helm/techwolf12/pretix>`_ and the source code is available on `GitHub <https://github.com/Techwolf12/charts/tree/main/pretix-helm>`_.
+1
View File
@@ -14,3 +14,4 @@ for your needs.
manual_smallscale
dev_version
enterprise
community
@@ -120,6 +120,7 @@ Now we will install pretix itself. The following steps are to be executed as the
actually install pretix, we will create a virtual environment to isolate the python packages from your global
python installation::
# sudo -u pretix -s
$ python3 -m venv /var/pretix/venv
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U pip setuptools wheel
@@ -279,6 +280,7 @@ Updates
To upgrade to a new pretix release, pull the latest code changes and run the following commands::
# sudo -u pretix -s
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
(venv)$ python -m pretix migrate
+6
View File
@@ -103,6 +103,12 @@ pretix_celery_tasks_queued_count
pretix_celery_tasks_queued_age_seconds
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
pretix_successful_logins
Counter. The number of successful backend logins.
pretix_failed_logins
Counter. The number of failed backend logins, labeled with ``reason``.
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
.. _Prometheus: https://prometheus.io/
.. _cProfile: https://docs.python.org/3/library/profile.html
+4 -1
View File
@@ -249,7 +249,10 @@ You can get three response codes:
Content-Type: application/json
{
"event": "democon",
"event": {
"name": "Demo Conference",
"slug": "democon"
},
"subevent": 23,
"checkinlist": 5
}
+7
View File
@@ -22,6 +22,8 @@ id integer Internal ID of
name string Team name
all_events boolean Whether this team has access to all events
limit_events list List of event slugs this team has access to
require_2fa boolean Whether members of this team are required to use
two-factor authentication
can_create_events boolean
can_change_teams boolean
can_change_organizer_settings boolean
@@ -122,6 +124,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
@@ -159,6 +162,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
@@ -186,6 +190,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
@@ -203,6 +208,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
@@ -246,6 +252,7 @@ Team endpoints
"name": "Admin team",
"all_events": true,
"limit_events": [],
"require_2fa": true,
"can_create_events": true,
...
}
+13 -6
View File
@@ -3,11 +3,12 @@
.. _`importcol`:
Extending the order import process
==================================
Extending the import process
============================
It's possible through the backend to import orders into pretix, for example from a legacy ticketing system. If your
plugins defines additional data structures around orders, it might be useful to make it possible to import them as well.
It's possible through the backend to import objects into pretix, for example orders from a legacy ticketing system. If
your plugin defines additional data structures around those objects, it might be useful to make it possible to import
them as well.
Import process
--------------
@@ -40,7 +41,7 @@ Column registration
The import API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available import columns. Your plugin
should listen for this signal and return the subclass of ``pretix.base.orderimport.ImportColumn``
should listen for this signal and return the subclass of ``pretix.base.modelimport.ImportColumn``
that we'll provide in this plugin:
.. sourcecode:: python
@@ -56,10 +57,16 @@ that we'll provide in this plugin:
EmailColumn(sender),
]
Similar signals exist for other objects:
.. automodule:: pretix.base.signals
:members: voucher_import_columns
The column class API
--------------------
.. class:: pretix.base.orderimport.ImportColumn
.. class:: pretix.base.modelimport.ImportColumn
The central object of each import extension is the subclass of ``ImportColumn``.
+1 -1
View File
@@ -1,4 +1,4 @@
sphinx==7.2.*
sphinx==7.3.*
jinja2==3.1.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
+1 -1
View File
@@ -1,5 +1,5 @@
-e ../
sphinx==7.2.*
sphinx==7.3.*
jinja2==3.1.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
+113
View File
@@ -0,0 +1,113 @@
Android version support policy
==============================
Building software for Android always presents a struggle between keeping compatibility with older hardware to save cost
and utilizing feature of new Android versions to improve functionality, security and stability. To help you plan ahead,
we are publishing our intended schedule. This is to be understood as a minimum commitment, we will only drop support for
older versions if there is a technical reason to do so, not because the scheduled time has been reached.
.. warning:: This is a non-binding document. We will try our very best to not to deprecate support for Android versions
earlier than listed here, but for technical or economical reasons, it might become necessary to do so under
specific circumstances. Specifically, we might be forced to partially drop support for Android versions
earlier where we integrate third-party components into our software. Typical examples would be specific
payment terminal or printer types where we use a third-party component provided by the hardware vendor.
If we no longer support an Android version, it means that we will no longer publish new versions of the app supporting
that Android version. This means you are not getting new features or bug fixes, and at some point your app might stop
working with the pretix server.
pretixSCAN
----------
=========================== ==========================================================
Android Version Support schedule
=========================== ==========================================================
Android 14 Support planned until at least 12/2029.
Android 13 Support planned until at least 12/2028.
Android 12 Support planned until at least 12/2027.
Android 11 Support planned until at least 12/2026.
Android 10 Support planned until at least 12/2025.
Android 9 Support planned until at least 12/2025.
Android 8 Support planned until at least 12/2025.
Android 7 Support planned until at least 06/2025.
Android 6 Support planned until at least 06/2025.
Android 5 | Support planned until at least 06/2025.
| No support for COVID certificate verification.
Android 4 Support dropped.
=========================== ==========================================================
pretixPOS
---------
=========================== ==========================================================
Android Version Support schedule
=========================== ==========================================================
Android 14 | Support planned until at least 12/2029.
| Limited support for Swissbit microSD TSE (only tested devices).
Android 13 | Support planned until at least 12/2028.
| Limited support for Swissbit microSD TSE (only tested devices).
Android 12 | Support planned until at least 12/2027.
| Limited support for Swissbit microSD TSE (only tested devices).
Android 11 | Support planned until at least 12/2026.
| No support for Swissbit microSD TSE.
Android 10 Support planned until at least 12/2025.
Android 9 Support planned until at least 12/2025.
Android 8 | Support planned until at least 12/2025.
| Support for Stripe Terminal on some devices to be dropped 05/2024.
Android 7 | Support planned until at least 12/2024.
| Support for Stripe Terminal to be dropped 05/2024.
| No support for Cryptovision TSE.
Android 6 | Support planned until at least 12/2024.
| No support for Cryptovision TSE.
| No support for Fiskal Cloud.
| No support for Stripe Terminal.
Android 5 | Support planned until at least 12/2024.
| No support for Cryptovision TSE.
| No support for Fiskal Cloud.
| No support for Stripe Terminal.
| No support for SumUp.
| No support for COVID certificate verification.
Android 4 Support dropped.
=========================== ==========================================================
pretixPRINT
-----------
=========================== ==========================================================
Android Version Support schedule
=========================== ==========================================================
Android 14 Support planned until at least 12/2029.
Android 13 Support planned until at least 12/2028.
Android 12 Support planned until at least 12/2027.
Android 11 Support planned until at least 12/2026.
Android 10 Support planned until at least 12/2025.
Android 9 Support planned until at least 12/2025.
Android 8 Support planned until at least 12/2025.
Android 7 Support planned until at least 06/2025.
Android 6 Support planned until at least 06/2025.
Android 5 | Support planned until at least 06/2025.
| No support for Evolis printers on some devices.
Android 4.4 | Support planned until at least 06/2024.
| No support for USB printers.
| No support for Evolis printers.
Android 4 Support dropped.
=========================== ==========================================================
pretixLEAD
----------
=========================== ==========================================================
Android Version Support schedule
=========================== ==========================================================
Android 14 Support planned until at least 12/2029.
Android 13 Support planned until at least 12/2028.
Android 12 Support planned until at least 12/2027.
Android 11 Support planned until at least 12/2026.
Android 10 Support planned until at least 12/2025.
Android 9 Support planned until at least 12/2025.
Android 8 Support planned until at least 12/2025.
Android 7 Support planned until at least 12/2024.
Android 6 Support planned until at least 12/2024.
Android 5 Support planned until at least 12/2024.
Android 4 Support dropped.
=========================== ==========================================================
+11 -2
View File
@@ -17,8 +17,8 @@ and then click "Generate widget code".
You will obtain two code snippets that look *roughly* like the following. The first should be embedded into the
``<head>`` part of your website, if possible. If this inconvenient, you can put it in the ``<body>`` part as well::
<link rel="stylesheet" type="text/css" href="https://pretix.eu/demo/democon/widget/v1.css">
<script type="text/javascript" src="https://pretix.eu/widget/v1.en.js" async></script>
<link rel="stylesheet" type="text/css" href="https://pretix.eu/demo/democon/widget/v1.css" crossorigin>
<script type="text/javascript" src="https://pretix.eu/widget/v1.en.js" async crossorigin></script>
The second snippet should be embedded at the position where the widget should show up::
@@ -449,5 +449,14 @@ Further reading:
* `Stripe Payment Method Domain registration`_
Working with Cross-Origin-Embedder-Policy
-----------------------------------------
The pretix widget is unfortunately not compatible with ``Cross-Origin-Embedder-Policy: require-corp``. If you include
the ``crossorigin`` attributes on the ``<script>`` and ``<link>`` tag as shown above, the widget can show a calendar
or product list, but will not be able to open the checkout process in an iframe. If you also set
``Cross-Origin-Opener-Policy: same-origin``, the widget can auto-detect that it is running in an isolated enviroment
and will instead open the checkout process in a new tab.
.. _Let's Encrypt: https://letsencrypt.org/
.. _Stripe Payment Method Domain registration: https://stripe.com/docs/payments/payment-methods/pmd-registration
+1
View File
@@ -16,4 +16,5 @@ wanting to use pretix to sell tickets.
events/giftcards
faq
markdown
android-version-support
glossary
+3
View File
@@ -11,6 +11,9 @@ In many places of your shop, like frontpage texts, product descriptions and emai
since it is way easier to learn than languages like HTML but allows all basic formatting options required
for text in those places.
.. note:: Some fields that are used in one-line context only allow formatting that refers to individual words
(such as bold or italic font or a link) but do not allow block-level formatting like lists or headlines.
Formatting rules
----------------
+19 -18
View File
@@ -30,17 +30,17 @@ dependencies = [
"babel",
"BeautifulSoup4==4.12.*",
"bleach==5.0.*",
"celery==5.3.*",
"celery==5.4.*",
"chardet==5.2.*",
"cryptography>=3.4.2",
"css-inline==0.13.*",
"css-inline==0.14.*",
"defusedcsv>=1.1.0",
"dj-static",
"Django==4.2.*",
"django-bootstrap3==23.6.*",
"Django[argon2]==4.2.*",
"django-bootstrap3==24.2",
"django-compressor==4.4",
"django-countries==7.5.*",
"django-filter==24.1",
"django-countries==7.6.*",
"django-filter==24.2",
"django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1",
"django-hierarkey==1.1.*",
@@ -50,12 +50,12 @@ dependencies = [
"django-localflavor==4.0",
"django-markup",
"django-oauth-toolkit==2.3.*",
"django-otp==1.3.*",
"django-otp==1.5.*",
"django-phonenumber-field==7.3.*",
"django-redis==5.4.*",
"django-scopes==2.0.*",
"django-statici18n==2.4.*",
"djangorestframework==3.14.*",
"django-statici18n==2.5.*",
"djangorestframework==3.15.*",
"dnspython==2.6.*",
"drf_ujson2==1.7.*",
"geoip2==4.*",
@@ -75,12 +75,12 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.8.*",
"phonenumberslite==8.13.*",
"Pillow==10.2.*",
"Pillow==10.3.*",
"pretix-plugin-build",
"protobuf==5.26.*",
"psycopg2-binary",
"pycountry",
"pycparser==2.21",
"pycparser==2.22",
"pycryptodome==3.20.*",
"pypdf==3.9.*",
"python-bidi==0.4.*", # Support for Arabic in reportlab
@@ -90,9 +90,9 @@ dependencies = [
"pyuca",
"qrcode==7.4.*",
"redis==5.0.*",
"reportlab==4.1.*",
"reportlab==4.2.*",
"requests==2.31.*",
"sentry-sdk==1.42.*",
"sentry-sdk==1.45.*",
"sepaxml==2.6.*",
"slimit",
"static3==0.7.*",
@@ -100,9 +100,10 @@ dependencies = [
"text-unidecode==1.*",
"tlds>=2020041600",
"tqdm==4.*",
"ua-parser==0.18.*",
"vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*",
"webauthn==2.0.*",
"webauthn==2.1.*",
"zeep==4.2.*"
]
@@ -112,7 +113,7 @@ dev = [
"aiohttp==3.9.*",
"coverage",
"coveralls",
"fakeredis==2.21.*",
"fakeredis==2.22.*",
"flake8==7.0.*",
"freezegun",
"isort==5.13.*",
@@ -122,11 +123,11 @@ dev = [
"pytest-cache",
"pytest-cov",
"pytest-django==4.*",
"pytest-mock==3.12.*",
"pytest-mock==3.14.*",
"pytest-rerunfailures==14.*",
"pytest-sugar",
"pytest-xdist==3.5.*",
"pytest==8.1.*",
"pytest-xdist==3.6.*",
"pytest==8.2.*",
"responses",
]
+4
View File
@@ -0,0 +1,4 @@
{
"ignore_dirs": ["node_modules", "data", "pretix/static", "pretix/locale", "pretix/static.dist"]
}
+1 -1
View File
@@ -6,7 +6,7 @@ localecompile:
./manage.py compilemessages
localegen:
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
staticfiles: jsi18n
+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__ = "2024.3.0"
__version__ = "2024.5.0.dev0"
+6 -1
View File
@@ -235,7 +235,12 @@ COMPRESS_FILTERS = {
)
}
CURRENCIES = list(currencies)
CURRENCIES = [
c for c in currencies
if c.alpha_3 not in {
'XAG', 'XAU', 'XBA', 'XBB', 'XBC', 'XBD', 'XDR', 'XPD', 'XPT', 'XSU', 'XTS', 'XUA',
}
]
CURRENCY_PLACES = {
# default is 2
'BIF': 0,
+14 -1
View File
@@ -39,7 +39,8 @@ from pretix.base.models import Device, Event, User
from pretix.base.models.auth import SuperuserPermissionSet
from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
SessionReauthRequired, assert_session_valid,
)
@@ -66,6 +67,10 @@ class EventPermission(BasePermission):
return False
except SessionReauthRequired:
return False
except Session2FASetupRequired:
return False
except SessionPasswordChangeRequired:
return False
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
else request.user)
@@ -144,6 +149,10 @@ class ProfilePermission(BasePermission):
return False
except SessionReauthRequired:
return False
except Session2FASetupRequired:
return False
except SessionPasswordChangeRequired:
return False
if isinstance(request.auth, OAuthAccessToken):
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
@@ -166,5 +175,9 @@ class AnyAuthenticatedClientPermission(BasePermission):
return False
except SessionReauthRequired:
return False
except Session2FASetupRequired:
return False
except SessionPasswordChangeRequired:
return False
return True
+2 -1
View File
@@ -472,7 +472,8 @@ class SubEventSerializer(I18nAwareModelSerializer):
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides',
'meta_data', 'seat_category_mapping', 'last_modified', 'best_availability_state')
'meta_data', 'seat_category_mapping', 'last_modified', 'best_availability_state',
'comment')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+6 -2
View File
@@ -1315,7 +1315,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
valid_from, valid_until = pos_data['item'].compute_validity(
requested_start=(
max(requested_valid_from, now())
requested_valid_from
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
else now()
),
@@ -1439,6 +1439,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
else:
pos._calculate_tax(invoice_address=ia)
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = listed_price
@@ -1466,7 +1467,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
pos = pos_data['__instance']
pos._calculate_tax()
pos._calculate_tax(invoice_address=ia)
if simulate:
pos = WrappedModel(pos)
@@ -1585,6 +1586,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
payment_provider = 'free'
if order.total != Decimal('0.00') and order.event.currency == "XXX":
raise ValidationError('Paid products not supported without a valid currency.')
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
order.status = Order.STATUS_PAID
order.save()
+1 -1
View File
@@ -239,7 +239,7 @@ class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = (
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
+1
View File
@@ -89,6 +89,7 @@ class SettingsSerializer(serializers.Serializer):
except OSError: # pragma: no cover
logger.error('Deleting file %s failed.' % fname.name)
instance.delete(attr)
self.changed_data.append(attr)
else:
# file is unchanged
continue
+14 -5
View File
@@ -190,7 +190,10 @@ class EventViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
@transaction.atomic()
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
current_live_value = serializer.instance.live
updated_live_value = serializer.validated_data.get('live', None)
current_plugins_value = serializer.instance.get_plugins()
@@ -198,6 +201,11 @@ class EventViewSet(viewsets.ModelViewSet):
super().perform_update(serializer)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
if updated_live_value is not None and updated_live_value != current_live_value:
log_action = 'pretix.event.live.activated' if updated_live_value else 'pretix.event.live.deactivated'
serializer.instance.log_action(
@@ -622,11 +630,12 @@ class EventSettingsView(views.APIView):
s.is_valid(raise_exception=True)
with transaction.atomic():
s.save()
self.request.event.log_action(
'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={
k: v for k, v in s.validated_data.items()
}
)
if s.changed_data:
self.request.event.log_action(
'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={
k: v for k, v in s.validated_data.items()
}
)
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
regenerate_css.apply_async(args=(request.event.pk,))
s = EventSettingsSerializer(
+15 -3
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import datetime
import logging
import mimetypes
import os
from decimal import Decimal
@@ -27,7 +28,7 @@ from zoneinfo import ZoneInfo
import django_filters
from django.conf import settings
from django.db import transaction
from django.db import IntegrityError, transaction
from django.db.models import (
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
)
@@ -96,6 +97,9 @@ from pretix.base.signals import (
)
from pretix.base.templatetags.money import money_filter
from pretix.control.signals import order_search_filter_q
from pretix.helpers import OF_SELF
logger = logging.getLogger(__name__)
with scopes_disabled():
class OrderFilter(FilterSet):
@@ -572,8 +576,10 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@transaction.atomic()
def create_invoice(self, request, **kwargs):
order = self.get_object()
order = Order.objects.select_for_update(of=OF_SELF).get(pk=order.pk)
has_inv = order.invoices.exists() and not (
order.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
and order.invoices.filter(is_cancellation=True).count() >= order.invoices.filter(is_cancellation=False).count()
@@ -900,7 +906,11 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
def perform_create(self, serializer):
serializer.save()
try:
serializer.save()
except IntegrityError:
logger.exception("Integrity error while saving order")
raise ValidationError("Integrity error, possibly duplicate submission of same order.")
def perform_destroy(self, instance):
if not instance.testmode:
@@ -1898,6 +1908,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return Response(status=204)
@action(detail=True, methods=['POST'])
@transaction.atomic()
def reissue(self, request, **kwargs):
inv = self.get_object()
if inv.canceled:
@@ -1905,9 +1916,10 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
elif inv.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
else:
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
c = generate_cancellation(inv)
if inv.order.status != Order.STATUS_CANCELED:
inv = generate_invoice(inv.order)
inv = generate_invoice(order)
else:
inv = c
inv.order.log_action(
+1 -1
View File
@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
from . import invoice # NOQA
from . import notifications # NOQA
from . import email # NOQA
from .services import auth, checkin, currencies, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
from .models import _transactions # NOQA
from django.conf import settings
+24 -1
View File
@@ -39,10 +39,12 @@ from zipfile import ZipFile
from django import forms
from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import QuestionAnswer
from ...control.forms.widgets import Select2
from ..exporter import BaseExporter
from ..signals import register_data_exporters
@@ -56,7 +58,7 @@ class AnswerFilesExporter(BaseExporter):
@property
def export_form_fields(self):
return OrderedDict(
d = OrderedDict(
[
('questions',
forms.ModelMultipleChoiceField(
@@ -69,11 +71,32 @@ class AnswerFilesExporter(BaseExporter):
)),
]
)
if self.event.has_subevents:
d['subevent'] = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
queryset=self.event.subevents.all(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
d['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
}
)
d['subevent'].widget.choices = d['subevent'].choices
return d
def render(self, form_data: dict):
qs = QuestionAnswer.objects.filter(
orderposition__order__event=self.event,
).select_related('orderposition', 'orderposition__order', 'question')
if form_data.get('subevent'):
qs = qs.filter(orderposition__subevent=form_data.get('subevent'))
if form_data.get('questions'):
qs = qs.filter(question__in=form_data['questions'])
with tempfile.TemporaryDirectory() as d:
+18 -4
View File
@@ -116,15 +116,29 @@ class DekodiNREIExporter(BaseExporter):
'PTNo15': p.full_id or '',
})
elif p.provider and p.provider.startswith('stripe'):
src = p.info_data.get("source", p.info_data)
pi = p.info_data or {}
try:
if "latest_charge" in pi and isinstance(pi.get("latest_charge"), dict):
details = pi["latest_charge"]["payment_method_details"]
card = details.get("card", {})
elif pi.get("charges") and pi["charges"]["data"]:
details = pi["charges"]["data"][0].get("payment_method_details", {})
card = details.get("card", {})
else:
details = pi["source"]
card = pi["source"]["card"]
except:
details = {}
card = {}
payments.append({
'PTID': '81',
'PTN': 'Stripe',
'PTNo1': p.info_data.get("id") or '',
'PTNo5': src.get("card", {}).get("last4") or '',
'PTNo1': pi.get("id") or '',
'PTNo5': card.get("last4", ""),
'PTNo7': round(float(p.amount), 2) or '',
'PTNo8': str(self.event.currency) or '',
'PTNo10': src.get('owner', {}).get('verified_name') or src.get('owner', {}).get('name') or '',
'PTNo10': details.get('owner', {}).get('verified_name') or details.get('owner', {}).get('name') or '',
'PTNo15': p.full_id or '',
})
else:
+38
View File
@@ -39,6 +39,7 @@ from django import forms
from django.core.validators import URLValidator
from django.forms.models import ModelFormMetaclass
from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from formtools.wizard.views import SessionWizardView
from hierarkey.forms import HierarkeyForm
@@ -85,6 +86,43 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
super().__init__(*args, **kwargs)
class MarkdownTextarea(forms.Textarea):
def _render(self, template_name, context, renderer=None):
return mark_safe(
'<div class="i18n-form-group">%s<div class="i18n-field-markdown-note">%s</div></div>' % (
super()._render(template_name, context, renderer=None),
_("You can use {markup_name} in this field.").format(
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
)
)
)
class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea):
def format_output(self, rendered_widgets) -> str:
rendered_widgets = rendered_widgets + [
'<div class="i18n-field-markdown-note">%s</div>' % (
_("You can use {markup_name} in this field.").format(
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
)
)
]
return super().format_output(rendered_widgets)
class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput):
def format_output(self, rendered_widgets) -> str:
rendered_widgets = rendered_widgets + [
'<div class="i18n-field-markdown-note">%s</div>' % (
_("You can use {markup_name} in this field.").format(
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
)
)
]
return super().format_output(rendered_widgets)
SECRET_REDACTED = '*****'
+11
View File
@@ -35,6 +35,7 @@
import hashlib
import ipaddress
import logging
from django import forms
from django.conf import settings
@@ -44,10 +45,13 @@ from django.contrib.auth.password_validation import (
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix.base.metrics import pretix_failed_logins
from pretix.base.models import User
from pretix.helpers.dicts import move_to_end
from pretix.helpers.http import get_client_ip
logger = logging.getLogger(__name__)
class LoginForm(forms.Form):
"""
@@ -55,6 +59,7 @@ class LoginForm(forms.Form):
username/password logins.
"""
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
origin = forms.CharField(widget=forms.HiddenInput, required=False)
error_messages = {
'invalid_login': _("This combination of credentials is not known to our system."),
@@ -104,12 +109,16 @@ class LoginForm(forms.Form):
rc = get_redis_connection("redis")
cnt = rc.get(self.ratelimit_key)
if cnt and int(cnt) > 10:
pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if self.user_cache is None:
if self.ratelimit_key:
rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 300)
logger.info("Backend login invalid.")
pretix_failed_logins.inc(1, reason="invalid")
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login'
@@ -131,6 +140,8 @@ class LoginForm(forms.Form):
If the given user may log in, this method should return None.
"""
if not user.is_active:
logger.info("Backend login rejected due to user inactive.")
pretix_failed_logins.inc(1, reason="inactive")
raise forms.ValidationError(
self.error_messages['inactive'],
code='inactive',
+6 -1
View File
@@ -268,7 +268,10 @@ def metric_values():
dkey = key.decode("utf-8")
splitted = dkey.split("{", 2)
value = float(value.decode("utf-8"))
metrics[splitted[0]]["{" + splitted[1]] = value
if len(splitted) == 1:
metrics[splitted[0]][""] = value
else:
metrics[splitted[0]]["{" + splitted[1]] = value
# Aliases
aliases = {
@@ -314,3 +317,5 @@ pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a cel
["task_name", "status"])
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
["task_name"])
pretix_successful_logins = Counter("pretix_logins_successful", "Successful logins", [])
pretix_failed_logins = Counter("pretix_logins_failed", "Failed logins", ["reason"])
@@ -0,0 +1,48 @@
# Generated by Django 4.2.10 on 2024-03-15 09:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0257_item_default_price_not_null"),
]
operations = [
migrations.AddField(
model_name="order",
name="organizer",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AddField(
model_name="orderposition",
name="organizer",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="order_positions",
to="pretixbase.organizer",
),
),
migrations.AddConstraint(
model_name="order",
constraint=models.UniqueConstraint(
fields=("organizer", "code"), name="order_organizer_code_uniq"
),
),
migrations.AddConstraint(
model_name="orderposition",
constraint=models.UniqueConstraint(
models.F("organizer"),
models.F("secret"),
name="orderposition_organizer_secret_uniq",
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-04-02 11:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0258_uniq_indx"),
]
operations = [
migrations.AddField(
model_name="team",
name="require_2fa",
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.2.10 on 2024-04-02 15:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0259_team_require_2fa"),
]
operations = [
migrations.AlterIndexTogether(
name="reusablemedium",
index_together=set(),
),
]
@@ -0,0 +1,48 @@
# Generated by Django 4.2.10 on 2024-04-02 15:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.helpers.countries
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0260_alter_reusablemedium_index_together"),
]
operations = [
migrations.CreateModel(
name="UserKnownLoginSource",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("agent_type", models.CharField(max_length=255, null=True)),
("device_type", models.CharField(max_length=255, null=True)),
("os_type", models.CharField(max_length=255, null=True)),
(
"country",
pretix.helpers.countries.FastCountryField(
countries=pretix.helpers.countries.CachedCountries,
max_length=2,
null=True,
),
),
("last_seen", models.DateTimeField()),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="known_login_sources",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-04-19 14:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0261_userknownloginsource"),
]
operations = [
migrations.AddField(
model_name="subevent",
name="comment",
field=models.TextField(null=True),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 4.2.10 on 2024-04-09 07:32
from django.db import migrations
def change_currencies(apps, schema_editor):
Event = apps.get_model("pretixbase", "Event")
Event.objects.filter(
currency__in={
'XAG', 'XAU', 'XBA', 'XBB', 'XBC', 'XBD', 'XDR', 'XPD', 'XPT', 'XSU', 'XTS', 'XUA',
}
).update(currency='XXX')
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0262_subevent_comment"),
]
operations = [
migrations.RunPython(
change_currencies, migrations.RunPython.noop
)
]
+287
View File
@@ -0,0 +1,287 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import csv
import datetime
import io
import re
from decimal import Decimal, DecimalException
from django.core.exceptions import ValidationError
from django.core.validators import validate_integer
from django.utils import formats
from django.utils.functional import cached_property
from django.utils.translation import gettext as _, gettext_lazy, pgettext
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import SubEvent
class DataImportError(LazyLocaleException):
def __init__(self, *args):
msg = args[0]
msgargs = args[1] if len(args) > 1 else None
self.args = args
if msgargs:
msg = _(msg) % msgargs
else:
msg = _(msg)
super().__init__(msg)
def parse_csv(file, length=None, mode="strict", charset=None):
file.seek(0)
data = file.read(length)
if not charset:
try:
import chardet
charset = chardet.detect(data)['encoding']
except ImportError:
charset = file.charset
data = data.decode(charset or "utf-8", mode)
# If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n')
try:
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
except csv.Error:
return None
if dialect is None:
return None
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
return reader
class ImportColumn:
@property
def identifier(self):
"""
Unique, internal name of the column.
"""
raise NotImplementedError
@property
def verbose_name(self):
"""
Human-readable description of the column
"""
raise NotImplementedError
@property
def initial(self):
"""
Initial value for the form component
"""
return None
@property
def default_value(self):
"""
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
option.
"""
return 'empty'
@property
def default_label(self):
"""
Human-readable description of the default assignment of this column, defaults to "Keep empty".
"""
return gettext_lazy('Keep empty')
def __init__(self, event):
self.event = event
def static_choices(self):
"""
This will be called when rendering the form component and allows you to return a list of values that can be
selected by the user statically during import.
:return: list of 2-tuples of strings
"""
return []
def resolve(self, settings, record):
"""
This method will be called to get the raw value for this field, usually by either using a static value or
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
the default should be fine.
"""
k = settings.get(self.identifier, self.default_value)
if k == self.default_value:
return None
elif k.startswith('csv:'):
return record.get(k[4:], None) or None
elif k.startswith('static:'):
return k[7:]
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
def clean(self, value, previous_values):
"""
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
You do not need to include the column or row name or value in the error message as it will automatically be
included.
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
e.g. if the column is empty or does not exist in this row.
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
"""
return value
def assign(self, value, obj, **kwargs):
"""
This will be called to perform the actual import. You are supposed to set attributes on the ``obj`` or other
related objects that get passed in based on the input ``value``. This is called *before* the actual database
transaction, so the input objects do not yet have a primary key. If you want to create related objects, you
need to place them into some sort of internal queue and persist them when ``save`` is called.
"""
pass
def save(self, obj):
"""
This will be called to perform the actual import. This is called inside the actual database transaction and the
input object ``obj`` has already been saved to the database.
"""
pass
@property
def timezone(self):
return self.event.timezone
def i18n_flat(l):
if isinstance(l.data, dict):
return l.data.values()
return [l.data]
class BooleanColumnMixin:
default_value = None
initial = "static:false"
def static_choices(self):
return (
("false", _("No")),
("true", _("Yes")),
)
def clean(self, value, previous_values):
if not value:
return False
if value.lower() in ("true", "1", "yes", _("Yes").lower()):
return True
elif value.lower() in ("false", "0", "no", _("No").lower()):
return False
else:
raise ValidationError(_("Could not parse {value} as a yes/no value.").format(value=value))
class DatetimeColumnMixin:
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.timezone)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
class DecimalColumnMixin:
def clean(self, value, previous_values):
if value not in (None, ''):
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
try:
value = Decimal(value)
except (DecimalException, TypeError):
raise ValidationError(_('You entered an invalid number.'))
return value
class IntegerColumnMixin:
def clean(self, value, previous_values):
if value is not None:
validate_integer(value)
return int(value)
class SubeventColumnMixin:
def __init__(self, *args, **kwargs):
self._subevent_cache = {}
super().__init__(*args, **kwargs)
@cached_property
def subevents(self):
return list(self.event.subevents.filter(active=True).order_by('date_from'))
def static_choices(self):
return [
(str(p.pk), str(p)) for p in self.subevents
]
def clean(self, value, previous_values):
if value in self._subevent_cache:
return self._subevent_cache[value]
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone)
try:
se = self.event.subevents.get(
active=True,
date_from__gt=d - datetime.timedelta(seconds=1),
date_from__lt=d + datetime.timedelta(seconds=1),
)
self._subevent_cache[value] = se
return se
except SubEvent.DoesNotExist:
raise ValidationError(pgettext("subevent", "No matching date was found."))
except SubEvent.MultipleObjectsReturned:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
except (ValueError, TypeError):
continue
matches = [
p for p in self.subevents
if str(p.pk) == value or any(
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
]
if len(matches) == 0:
raise ValidationError(pgettext("subevent", "No matching date was found."))
if len(matches) > 1:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
self._subevent_cache[value] = matches[0]
return matches[0]
@@ -19,17 +19,13 @@
# 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 datetime
import re
from collections import defaultdict
from decimal import Decimal, DecimalException
import pycountry
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.db.models import Q
from django.utils import formats
from django.utils.functional import cached_property
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
@@ -42,9 +38,13 @@ from phonenumbers import SUPPORTED_REGIONS
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.questions import guess_country
from pretix.base.modelimport import (
DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
i18n_flat,
)
from pretix.base.models import (
Customer, ItemVariation, OrderPosition, Question, QuestionAnswer,
QuestionOption, Seat, SubEvent,
QuestionOption, Seat,
)
from pretix.base.services.pricing import get_price
from pretix.base.settings import (
@@ -53,99 +53,6 @@ from pretix.base.settings import (
from pretix.base.signals import order_import_columns
class ImportColumn:
@property
def identifier(self):
"""
Unique, internal name of the column.
"""
raise NotImplementedError
@property
def verbose_name(self):
"""
Human-readable description of the column
"""
raise NotImplementedError
@property
def initial(self):
"""
Initial value for the form component
"""
return None
@property
def default_value(self):
"""
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
option.
"""
return 'empty'
@property
def default_label(self):
"""
Human-readable description of the default assignment of this column, defaults to "Keep empty".
"""
return gettext_lazy('Keep empty')
def __init__(self, event):
self.event = event
def static_choices(self):
"""
This will be called when rendering the form component and allows you to return a list of values that can be
selected by the user statically during import.
:return: list of 2-tuples of strings
"""
return []
def resolve(self, settings, record):
"""
This method will be called to get the raw value for this field, usually by either using a static value or
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
the default should be fine.
"""
k = settings.get(self.identifier, self.default_value)
if k == self.default_value:
return None
elif k.startswith('csv:'):
return record.get(k[4:], None) or None
elif k.startswith('static:'):
return k[7:]
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
def clean(self, value, previous_values):
"""
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
You do not need to include the column or row name or value in the error message as it will automatically be
included.
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
e.g. if the column is empty or does not exist in this row.
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
"""
return value
def assign(self, value, order, position, invoice_address, **kwargs):
"""
This will be called to perform the actual import. You are supposed to set attributes on the ``order``, ``position``,
or ``invoice_address`` objects based on the input ``value``. This is called *before* the actual database
transaction, so these three objects do not yet have a primary key. If you want to create related objects, you
need to place them into some sort of internal queue and persist them when ``save`` is called.
"""
pass
def save(self, order):
"""
This will be called to perform the actual import. This is called inside the actual database transaction and the
input object ``order`` has already been saved to the database.
"""
pass
class EmailColumn(ImportColumn):
identifier = 'email'
verbose_name = gettext_lazy('E-mail address')
@@ -182,74 +89,20 @@ class PhoneColumn(ImportColumn):
order.phone = value
class SubeventColumn(ImportColumn):
class SubeventColumn(SubeventColumnMixin, ImportColumn):
identifier = 'subevent'
verbose_name = pgettext_lazy('subevents', 'Date')
default_value = None
def __init__(self, *args, **kwargs):
self._subevent_cache = {}
super().__init__(*args, **kwargs)
@cached_property
def subevents(self):
return list(self.event.subevents.filter(active=True).order_by('date_from'))
def static_choices(self):
return [
(str(p.pk), str(p)) for p in self.subevents
]
def clean(self, value, previous_values):
if not value:
raise ValidationError(pgettext("subevent", "You need to select a date."))
if value in self._subevent_cache:
return self._subevent_cache[value]
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone)
try:
se = self.event.subevents.get(
active=True,
date_from__gt=d - datetime.timedelta(seconds=1),
date_from__lt=d + datetime.timedelta(seconds=1),
)
self._subevent_cache[value] = se
return se
except SubEvent.DoesNotExist:
raise ValidationError(pgettext("subevent", "No matching date was found."))
except SubEvent.MultipleObjectsReturned:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
except (ValueError, TypeError):
continue
matches = [
p for p in self.subevents
if str(p.pk) == value or any(
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
]
if len(matches) == 0:
raise ValidationError(pgettext("subevent", "No matching date was found."))
if len(matches) > 1:
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
self._subevent_cache[value] = matches[0]
return matches[0]
return super().clean(value, previous_values)
def assign(self, value, order, position, invoice_address, **kwargs):
position.subevent = value
def i18n_flat(l):
if isinstance(l.data, dict):
return l.data.values()
return [l.data]
class ItemColumn(ImportColumn):
identifier = 'item'
verbose_name = gettext_lazy('Product')
@@ -572,20 +425,11 @@ class AttendeeState(ImportColumn):
position.state = value or ''
class Price(ImportColumn):
class Price(DecimalColumnMixin, ImportColumn):
identifier = 'price'
verbose_name = gettext_lazy('Price')
default_label = gettext_lazy('Calculate from product')
def clean(self, value, previous_values):
if value not in (None, ''):
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
try:
value = Decimal(value)
except (DecimalException, TypeError):
raise ValidationError(_('You entered an invalid number.'))
return value
def assign(self, value, order, position, invoice_address, **kwargs):
if value is None:
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
@@ -649,48 +493,18 @@ class Locale(ImportColumn):
order.locale = value
class ValidFrom(ImportColumn):
class ValidFrom(DatetimeColumnMixin, ImportColumn):
identifier = 'valid_from'
verbose_name = gettext_lazy('Valid from')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
def assign(self, value, order, position, invoice_address, **kwargs):
position.valid_from = value
class ValidUntil(ImportColumn):
class ValidUntil(DatetimeColumnMixin, ImportColumn):
identifier = 'valid_until'
verbose_name = gettext_lazy('Valid until')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
def assign(self, value, order, position, invoice_address, **kwargs):
position.valid_until = value
@@ -849,7 +663,7 @@ class CustomerColumn(ImportColumn):
order.customer = value
def get_all_columns(event):
def get_order_import_columns(event):
default = []
if event.has_subevents:
default.append(SubeventColumn(event))
+385
View File
@@ -0,0 +1,385 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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 decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.utils.functional import cached_property
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pretix.base.modelimport import (
BooleanColumnMixin, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn,
IntegerColumnMixin, i18n_flat,
)
from pretix.base.models import ItemVariation, Quota, Seat, Voucher
from pretix.base.signals import voucher_import_columns
class CodeColumn(ImportColumn):
identifier = 'code'
verbose_name = gettext_lazy('Voucher code')
default_value = None
def __init__(self, *args):
self._cached = set()
super().__init__(*args)
def clean(self, value, previous_values):
if value:
MinLengthValidator(5)(value)
if value and (value in self._cached or Voucher.objects.filter(event=self.event, code=value).exists()):
raise ValidationError(_('A voucher with this code already exists.'))
self._cached.add(value)
return value
def assign(self, value, obj: Voucher, **kwargs):
obj.code = value
class SubeventColumn(ImportColumn):
identifier = 'subevent'
verbose_name = pgettext_lazy('subevents', 'Date')
def assign(self, value, obj: Voucher, **kwargs):
obj.subevent = value
class MaxUsagesColumn(IntegerColumnMixin, ImportColumn):
identifier = 'max_usages'
verbose_name = gettext_lazy('Maximum usages')
default_value = None
initial = "static:1"
def static_choices(self):
return [
("1", "1")
]
def clean(self, value, previous_values):
if value is None:
raise ValidationError(_('The maximum number of usages must be set.'))
return super().clean(value, previous_values)
def assign(self, value, obj: Voucher, **kwargs):
obj.max_usages = value if value is not None else 1
class MinUsagesColumn(IntegerColumnMixin, ImportColumn):
identifier = 'min_usages'
verbose_name = gettext_lazy('Minimum usages')
default_value = None
initial = "static:1"
def static_choices(self):
return [
("1", "1")
]
def assign(self, value, obj: Voucher, **kwargs):
obj.min_usages = value if value is not None else 1
class BudgetColumn(DecimalColumnMixin, ImportColumn):
identifier = 'budget'
verbose_name = gettext_lazy('Maximum discount budget')
def assign(self, value, obj: Voucher, **kwargs):
obj.budget = value
class ValidUntilColumn(DatetimeColumnMixin, ImportColumn):
identifier = 'valid_until'
verbose_name = gettext_lazy('Valid until')
def assign(self, value, obj: Voucher, **kwargs):
obj.valid_until = value
class BlockQuotaColumn(BooleanColumnMixin, ImportColumn):
identifier = 'block_quota'
verbose_name = gettext_lazy('Reserve ticket from quota')
def assign(self, value, obj: Voucher, **kwargs):
obj.block_quota = value
class AllowIgnoreQuotaColumn(BooleanColumnMixin, ImportColumn):
identifier = 'allow_ignore_quota'
verbose_name = gettext_lazy('Allow to bypass quota')
def assign(self, value, obj: Voucher, **kwargs):
obj.allow_ignore_quota = value
class PriceModeColumn(ImportColumn):
identifier = 'price_mode'
verbose_name = gettext_lazy('Price mode')
default_value = None
initial = 'static:none'
def static_choices(self):
return Voucher.PRICE_MODES
def clean(self, value, previous_values):
d = dict(Voucher.PRICE_MODES)
reverse = {v: k for k, v in Voucher.PRICE_MODES}
if value in d:
return value
elif value in reverse:
return reverse[value]
else:
raise ValidationError(_("Could not parse {value} as a price mode, use one of {options}.").format(
value=value, options=', '.join(d.keys())
))
def assign(self, value, voucher: Voucher, **kwargs):
voucher.price_mode = value
class ValueColumn(DecimalColumnMixin, ImportColumn):
identifier = 'value'
verbose_name = gettext_lazy('Voucher value')
def clean(self, value, previous_values):
value = super().clean(value, previous_values)
if value and previous_values.get("price_mode") == "none":
raise ValidationError(_("It is pointless to set a value without a price mode."))
return value
def assign(self, value, obj: Voucher, **kwargs):
obj.value = value or Decimal("0.00")
class ItemColumn(ImportColumn):
identifier = 'item'
verbose_name = gettext_lazy('Product')
@cached_property
def items(self):
return list(self.event.items.filter(active=True))
def static_choices(self):
return [
(str(p.pk), str(p)) for p in self.items
]
def clean(self, value, previous_values):
if not value:
return
matches = [
p for p in self.items
if str(p.pk) == value or (p.internal_name and p.internal_name == value) or any(
(v and v == value) for v in i18n_flat(p.name))
]
if len(matches) == 0:
raise ValidationError(_("No matching product was found."))
if len(matches) > 1:
raise ValidationError(_("Multiple matching products were found."))
return matches[0]
def assign(self, value, voucher, **kwargs):
voucher.item = value
class VariationColumn(ImportColumn):
identifier = 'variation'
verbose_name = gettext_lazy('Product variation')
@cached_property
def items(self):
return list(ItemVariation.objects.filter(
active=True, item__active=True, item__event=self.event
).select_related('item'))
def static_choices(self):
return [
(str(p.pk), '{} {}'.format(p.item, p.value)) for p in self.items
]
def clean(self, value, previous_values):
if value:
matches = [
p for p in self.items
if (str(p.pk) == value or any((v and v == value) for v in i18n_flat(p.value))) and p.item_id == previous_values['item'].pk
]
if len(matches) == 0:
raise ValidationError(_("No matching variation was found."))
if len(matches) > 1:
raise ValidationError(_("Multiple matching variations were found."))
return matches[0]
return value
def assign(self, value, voucher: Voucher, **kwargs):
voucher.variation = value
class QuotaColumn(ImportColumn):
identifier = 'quota'
verbose_name = gettext_lazy('Quota')
@cached_property
def quotas(self):
return list(Quota.objects.filter(
event=self.event
))
def static_choices(self):
return [
(str(q.pk), q.name) for q in self.quotas
]
def clean(self, value, previous_values):
if value:
if previous_values.get('item'):
raise ValidationError(_("You cannot specify a quota if you specified a product."))
matches = [
q for q in self.quotas
if str(q.pk) == value or q.name == value
]
if len(matches) == 0:
raise ValidationError(_("No matching variation was found."))
if len(matches) > 1:
raise ValidationError(_("Multiple matching variations were found."))
return matches[0]
return value
def assign(self, value, voucher: Voucher, **kwargs):
voucher.quota = value
class SeatColumn(ImportColumn):
identifier = 'seat'
verbose_name = gettext_lazy('Seat ID')
def __init__(self, *args):
self._cached = set()
super().__init__(*args)
def clean(self, value, previous_values):
if value:
if self.event.has_subevents:
if not previous_values.get('subevent'):
raise ValidationError(_('You need to choose a date if you select a seat.'))
try:
value = Seat.objects.get(
event=self.event,
seat_guid=value,
subevent=previous_values.get('subevent')
)
except Seat.MultipleObjectsReturned:
raise ValidationError(_('Multiple matching seats were found.'))
except Seat.DoesNotExist:
raise ValidationError(_('No matching seat was found.'))
if not value.is_available() or value in self._cached:
raise ValidationError(
_('The seat you selected has already been taken. Please select a different seat.'))
if previous_values.get("quota"):
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
if previous_values.get('max_usages', 1) > 1 or previous_values.get('min_usages', 1) > 1:
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
if previous_values.get("item") and value.product != previous_values.get("item"):
raise ValidationError(
_('You need to choose the product "{prod}" for this seat.').format(prod=value.product)
)
self._cached.add(value)
return value
def assign(self, value, voucher: Voucher, **kwargs):
voucher.seat = value
class TagColumn(ImportColumn):
identifier = 'tag'
verbose_name = gettext_lazy('Tag')
def assign(self, value, voucher: Voucher, **kwargs):
voucher.tag = value or ''
class CommentColumn(ImportColumn):
identifier = 'comment'
verbose_name = gettext_lazy('Comment')
def assign(self, value, voucher: Voucher, **kwargs):
voucher.comment = value or ''
class ShowHiddenItemsColumn(BooleanColumnMixin, ImportColumn):
identifier = 'show_hidden_items'
verbose_name = gettext_lazy('Shows hidden products that match this voucher')
initial = "static:true"
def assign(self, value, obj: Voucher, **kwargs):
obj.show_hidden_items = value
class AllAddonsIncludedColumn(BooleanColumnMixin, ImportColumn):
identifier = 'all_addons_included'
verbose_name = gettext_lazy('Offer all add-on products for free when redeeming this voucher')
def assign(self, value, obj: Voucher, **kwargs):
obj.all_addons_included = value
class AllBundlesIncludedColumn(BooleanColumnMixin, ImportColumn):
identifier = 'all_bundles_included'
verbose_name = gettext_lazy('Include all bundled products without a designated price when redeeming this voucher')
def assign(self, value, obj: Voucher, **kwargs):
obj.all_bundles_included = value
def get_voucher_import_columns(event):
default = []
if event.has_subevents:
default.append(SubeventColumn(event))
default += [
CodeColumn(event),
MaxUsagesColumn(event),
MinUsagesColumn(event),
BudgetColumn(event),
ValidUntilColumn(event),
BlockQuotaColumn(event),
AllowIgnoreQuotaColumn(event),
PriceModeColumn(event),
ValueColumn(event),
ItemColumn(event),
VariationColumn(event),
QuotaColumn(event),
SeatColumn(event),
TagColumn(event),
CommentColumn(event),
ShowHiddenItemsColumn(event),
AllAddonsIncludedColumn(event),
AllBundlesIncludedColumn(event),
]
for recv, resp in voucher_import_columns.send(sender=event):
default += resp
return default
+10
View File
@@ -56,6 +56,7 @@ from webauthn.helpers.structs import PublicKeyCredentialDescriptor
from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri
from ...helpers.countries import FastCountryField
from ...helpers.u2f import pub_key_from_der, websafe_decode
from .base import LoggingMixin
@@ -579,6 +580,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
self.save(update_fields=['session_token'])
class UserKnownLoginSource(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
agent_type = models.CharField(max_length=255, null=True, blank=True)
device_type = models.CharField(max_length=255, null=True, blank=True)
os_type = models.CharField(max_length=255, null=True, blank=True)
country = FastCountryField(null=True, blank=True)
last_seen = models.DateTimeField()
class StaffSession(models.Model):
user = models.ForeignKey('User', on_delete=models.PROTECT)
date_start = models.DateTimeField(auto_now_add=True)
+1 -1
View File
@@ -285,7 +285,7 @@ class CheckinList(LoggedModel):
}
allowed_vars = {
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
'minutes_since_last_entry', 'minutes_since_first_entry', 'gate',
'minutes_since_last_entry', 'minutes_since_first_entry', 'gate', 'entry_status',
}
if not rules or not isinstance(rules, dict):
return rules
+2 -1
View File
@@ -23,6 +23,7 @@
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from math import ceil
from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError
@@ -272,7 +273,7 @@ class Discount(LoggedModel):
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
n_groups = min(len(condition_idx_group) // self.condition_min_count, len(benefit_idx_group))
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
else:
+8 -1
View File
@@ -1237,6 +1237,9 @@ class Event(EventMixin, LoggedModel):
if self.has_paid_things and not self.has_payment_provider:
issues.append(_('You have configured at least one paid product but have not enabled any payment methods.'))
if self.has_paid_things and self.currency == "XXX":
issues.append(_('You have configured at least one paid product but have not configured a currency.'))
if not self.quotas.exists():
issues.append(_('You need to configure at least one quota to sell anything.'))
@@ -1458,13 +1461,17 @@ class SubEvent(EventMixin, LoggedModel):
)
frontpage_text = I18nTextField(
null=True, blank=True,
verbose_name=_("Frontpage text")
verbose_name=_("Frontpage text"),
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents', verbose_name=_('Seating plan'))
items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
comment = models.TextField(
verbose_name=_("Internal comment"),
null=True, blank=True
)
last_modified = models.DateTimeField(
auto_now=True, db_index=True
-1
View File
@@ -122,7 +122,6 @@ class ReusableMedium(LoggedModel):
class Meta:
unique_together = (("identifier", "type", "organizer"),)
indexes = [
models.Index(fields=("identifier", "type", "organizer")),
models.Index(fields=("updated", "id")),
]
ordering = "identifier", "type", "organizer"
+45 -3
View File
@@ -188,6 +188,14 @@ class Order(LockModel, LoggedModel):
default=False,
)
testmode = models.BooleanField(default=False)
organizer = models.ForeignKey(
# Redundant foreign key, but is required for a uniqueness constraint
"Organizer",
related_name="orders",
on_delete=models.CASCADE,
null=True,
blank=True,
)
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
@@ -286,6 +294,9 @@ class Order(LockModel, LoggedModel):
models.Index(fields=["datetime", "id"]),
models.Index(fields=["last_modified", "id"]),
]
constraints = [
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
]
def __str__(self):
return self.full_code
@@ -499,6 +510,10 @@ class Order(LockModel, LoggedModel):
self.set_expires()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'expires'}.union(kwargs['update_fields'])
if not self.organizer_id:
self.organizer_id = self.event.organizer_id
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
is_new = not self.pk
update_fields = kwargs.get('update_fields', [])
@@ -2356,6 +2371,14 @@ class OrderPosition(AbstractPosition):
"""
positionid = models.PositiveIntegerField(default=1)
organizer = models.ForeignKey(
# Redundant foreign key, but is required for a uniqueness constraint
"Organizer",
related_name="order_positions",
on_delete=models.CASCADE,
null=True,
blank=True,
)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
@@ -2429,6 +2452,9 @@ class OrderPosition(AbstractPosition):
verbose_name = _("Order position")
verbose_name_plural = _("Order positions")
ordering = ("positionid", "id")
constraints = [
models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq")
]
@cached_property
def sort_key(self):
@@ -2498,7 +2524,8 @@ class OrderPosition(AbstractPosition):
op = OrderPosition(order=order)
for f in AbstractPosition._meta.fields:
if f.name == 'addon_to':
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
if cartpos.addon_to_id:
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
else:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
@@ -2518,6 +2545,9 @@ class OrderPosition(AbstractPosition):
op.valid_from = valid_from
op.valid_until = valid_until
if op.is_bundled and not op.addon_to_id:
raise ValueError("Bundled cart position without parent does not make sense.")
op.positionid = i + 1
op.save()
ops.append(op)
@@ -2552,10 +2582,10 @@ class OrderPosition(AbstractPosition):
self.item.id, self.variation.id if self.variation else 0, self.order_id
)
def _calculate_tax(self, tax_rule=None):
def _calculate_tax(self, tax_rule=None, invoice_address=None):
self.tax_rule = tax_rule or self.item.tax_rule
try:
ia = self.order.invoice_address
ia = invoice_address or self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
if self.tax_rule:
@@ -2585,6 +2615,10 @@ class OrderPosition(AbstractPosition):
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'secret'}.union(kwargs['update_fields'])
if not self.organizer_id:
self.organizer_id = self.order.event.organizer_id
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
if not self.blocked and self.blocked is not None:
self.blocked = None
if 'update_fields' in kwargs:
@@ -2932,6 +2966,14 @@ class CartPosition(AbstractPosition):
self.item.id, self.variation.id if self.variation else 0, self.cart_id
)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# invalidate cached values of cached properties that likely have changed
try:
del self.sort_key
except AttributeError:
pass
@property
def tax_value(self):
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
+6
View File
@@ -263,6 +263,12 @@ class Team(LoggedModel):
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
require_2fa = models.BooleanField(
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"),
help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
"authentication or leave the team. The setting may take a few minutes to become effective for "
"all users.")
)
can_create_events = models.BooleanField(
default=False,
-3
View File
@@ -517,9 +517,6 @@ class Voucher(LoggedModel):
if item and seat.product != item:
raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product))
if not seat.is_available(ignore_voucher_id=pk):
raise ValidationError(_('The seat "{id}" is already sold or currently blocked.').format(id=seat.seat_guid))
return seat
def save(self, *args, **kwargs):
+5 -5
View File
@@ -57,7 +57,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import PlaceholderValidator
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
from pretix.base.models import (
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
OrderRefund, Quota, TaxRule,
@@ -1185,14 +1185,14 @@ class ManualPayment(BasePaymentProvider):
label=_('Payment process description during checkout'),
help_text=_('This text will be shown during checkout when the user selects this payment method. '
'It should give a short explanation on this payment method.'),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)),
('email_instructions', I18nFormField(
label=_('Payment process description in order confirmation emails'),
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
'mails. It should instruct the user on how to proceed with the payment. You can use '
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
)),
('pending_description', I18nFormField(
@@ -1200,7 +1200,7 @@ class ManualPayment(BasePaymentProvider):
help_text=_('This text will be shown on the order confirmation page for pending orders. '
'It should instruct the user on how to proceed with the payment. You can use '
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
)),
('invoice_immediately',
@@ -1325,7 +1325,7 @@ class GiftCardPayment(BasePaymentProvider):
(
"public_description",
I18nFormField(
label=_("Payment method description"), widget=I18nTextarea, required=False
label=_("Payment method description"), widget=I18nMarkdownTextarea, required=False
),
),
]
+5 -4
View File
@@ -935,10 +935,6 @@ class Renderer:
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
font = o['fontfamily']
if o['bold']:
font += ' B'
if o['italic']:
font += ' I'
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
# should not have access to.
@@ -946,6 +942,11 @@ class Renderer:
logger.warning(f'Unauthorized use of font "{font}"')
font = 'Open Sans'
if o['bold']:
font += ' B'
if o['italic']:
font += ' I'
try:
ad = getAscentDescent(font, float(o['fontsize']))
except KeyError: # font not known, fall back
+2
View File
@@ -257,6 +257,8 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
kwargs['valid_from'] = position.valid_from
if 'valid_until' in params:
kwargs['valid_until'] = position.valid_until
if 'order_datetime' in params:
kwargs['order_datetime'] = position.order.datetime
secret = gen.generate_secret(
item=position.item,
variation=position.variation,
+1 -1
View File
@@ -1358,7 +1358,7 @@ class CartManager:
return err
def recompute_final_prices_and_taxes(self):
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
positions = sorted(list(self.positions), key=lambda cp: (-(cp.addon_to_id or 0), cp.pk))
diff = Decimal('0.00')
for cp in positions:
if cp.listed_price is None:
+38 -2
View File
@@ -42,8 +42,8 @@ from dateutil.tz import datetime_exists
from django.core.files import File
from django.db import IntegrityError, transaction
from django.db.models import (
BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, Value,
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, TextField, Value, When,
)
from django.db.models.functions import Coalesce, TruncDate
from django.dispatch import receiver
@@ -273,6 +273,14 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
var_texts[vname] = _('Only allowed before {datetime}').format(datetime=compare_to_text)
elif operator == 'isAfter':
var_texts[vname] = _('Only allowed after {datetime}').format(datetime=compare_to_text)
elif var == 'entry_status':
var_weights[vname] = (20, 0)
if operator == '==' and rhs[0] == 'present':
var_texts[vname] = _('Attendee is checked out')
elif operator == '==' and rhs[0] == 'absent':
var_texts[vname] = _('Attendee is already checked in')
else:
var_texts[vname] = f'{var} not {operator} {rhs}'
elif var == 'product' or var == 'variation':
var_weights[vname] = (1000, 0)
var_texts[vname] = _('Ticket type not allowed')
@@ -507,6 +515,13 @@ class LazyRuleVars:
day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count()
@cached_property
def entry_status(self):
last_checkin = self._position.checkins.filter(list=self._clist).order_by('datetime').last()
if not last_checkin or last_checkin.type == Checkin.TYPE_EXIT:
return "absent"
return "present"
@cached_property
def minutes_since_last_entry(self):
tz = self._clist.event.timezone
@@ -569,6 +584,8 @@ class SQLLogic:
'entries_days_since', 'entries_days_before'}
def operation_to_expression(self, rule):
if isinstance(rule, str):
return Value(rule)
if not isinstance(rule, dict):
return rule
@@ -770,6 +787,25 @@ class SQLLogic:
Value(-1),
output_field=IntegerField()
)
elif values[0] == 'entry_status':
sq_last_checkin = Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
list_id=self.list.pk,
).order_by('-datetime').values('type')[:1]
)
return Case(
When(
condition=Equal(
sq_last_checkin,
Value(Checkin.TYPE_ENTRY)
),
then=Value("present"),
),
default=Value("absent"),
output_field=TextField()
)
else:
raise ValueError(f'Unknown operator {operator}')
+7
View File
@@ -31,6 +31,7 @@ from pretix.base.models import CachedCombinedTicket, CachedTicket
from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource
from ..signals import periodic_task
@@ -75,3 +76,9 @@ def clearsessions(sender, **kwargs):
@scopes_disabled()
def clear_oidc_data(sender, **kwargs):
CustomerSSOGrant.objects.filter(expires__lt=now() - timedelta(days=14)).delete()
@receiver(signal=periodic_task)
@scopes_disabled()
def clear_old_login_sources(sender, **kwargs):
UserKnownLoginSource.objects.filter(last_seen__lt=now() - timedelta(days=365)).delete()
+2 -1
View File
@@ -470,7 +470,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
# just "ABC-123.pdf", but we only do so if our currently selected language allows to do this
# as ASCII text. For example, we would not want a "فاتورة_" prefix for our filename since this
# has shown to cause deliverability problems of the email and deliverability wins.
filename = pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf'
with language(order.locale if order else inv.locale, event.settings.region if event else None):
filename = pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf'
if not re.match("^[a-zA-Z0-9-_%./,&:# ]+$", filename):
filename = inv.number.replace(' ', '_') + '.pdf'
filename = re.sub("[^a-zA-Z0-9-_.]+", "_", filename)
@@ -19,9 +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 csv
import io
from decimal import Decimal
from typing import List
from django.conf import settings as django_settings
from django.core.exceptions import ValidationError
@@ -29,13 +28,15 @@ from django.db import transaction
from django.utils.timezone import now
from django.utils.translation import gettext as _
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.i18n import language
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
from pretix.base.modelimport_orders import get_order_import_columns
from pretix.base.modelimport_vouchers import get_voucher_import_columns
from pretix.base.models import (
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
User,
User, Voucher,
)
from pretix.base.models.orders import Transaction
from pretix.base.orderimport import get_all_columns
from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.locking import lock_objects
from pretix.base.services.tasks import ProfiledEventTask
@@ -43,47 +44,36 @@ from pretix.base.signals import order_paid, order_placed
from pretix.celery_app import app
class DataImportError(LazyLocaleException):
def __init__(self, *args):
msg = args[0]
msgargs = args[1] if len(args) > 1 else None
self.args = args
if msgargs:
msg = _(msg) % msgargs
else:
msg = _(msg)
super().__init__(msg)
def parse_csv(file, length=None, mode="strict", charset=None):
file.seek(0)
data = file.read(length)
if not charset:
try:
import chardet
charset = chardet.detect(data)['encoding']
except ImportError:
charset = file.charset
data = data.decode(charset or "utf-8", mode)
# If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n')
def _validate(cf: CachedFile, charset: str, cols: List[ImportColumn], settings: dict):
try:
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
except csv.Error:
return None
if dialect is None:
return None
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
return reader
def setif(record, obj, attr, setting):
if setting.startswith('csv:'):
setattr(obj, attr, record[setting[4:]] or '')
parsed = parse_csv(cf.file, charset=charset)
except UnicodeDecodeError as e:
raise DataImportError(
_(
'Error decoding special characters in your file: {message}').format(
message=str(e)
)
)
data = []
for i, record in enumerate(parsed):
if not any(record.values()):
continue
values = {}
for c in cols:
val = c.resolve(settings, record)
if isinstance(val, str):
val = val.strip()
try:
values[c.identifier] = c.clean(val, values)
except ValidationError as e:
raise DataImportError(
_(
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
)
)
data.append(values)
return data
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
@@ -91,45 +81,17 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
cf = CachedFile.objects.get(id=fileid)
user = User.objects.get(pk=user)
with language(locale, event.settings.region):
cols = get_all_columns(event)
try:
parsed = parse_csv(cf.file, charset=charset)
except UnicodeDecodeError as e:
raise DataImportError(
_(
'Error decoding special characters in your file: {message}').format(
message=str(e)
)
)
orders = []
order = None
data = []
# Run validation
for i, record in enumerate(parsed):
if not any(record.values()):
continue
values = {}
for c in cols:
val = c.resolve(settings, record)
if isinstance(val, str):
val = val.strip()
try:
values[c.identifier] = c.clean(val, values)
except ValidationError as e:
raise DataImportError(
_(
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
)
)
data.append(values)
cols = get_order_import_columns(event)
data = _validate(cf, charset, cols, settings)
if settings['orders'] == 'one' and len(data) > django_settings.PRETIX_MAX_ORDER_SIZE:
raise DataImportError(
_('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE}
)
orders = []
order = None
# 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…
lock_seats = []
@@ -149,16 +111,16 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
position = OrderPosition(positionid=len(order._positions) + 1)
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
position.meta_info = {}
if position.seat is not None:
lock_seats.append(position.seat)
order._positions.append(position)
position.assign_pseudonymization_id()
for c in cols:
c.assign(record.get(c.identifier), order, position, order._address)
except ImportError as e:
raise ImportError(
if position.seat is not None:
lock_seats.append(position.seat)
except (ValidationError, ImportError) as e:
raise DataImportError(
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
)
@@ -169,7 +131,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
lock_objects(lock_seats, shared_lock_objects=[event])
for s in lock_seats:
if not s.is_available():
raise ImportError(_('The seat you selected has already been taken. Please select a different seat.'))
raise DataImportError(_('The seat you selected has already been taken. Please select a different seat.'))
save_transactions = []
for o in orders:
@@ -232,3 +194,64 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
raise ValidationError(_('We were not able to process your request completely as the server was too busy. '
'Please try again.'))
cf.delete()
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user, charset=None) -> None:
cf = CachedFile.objects.get(id=fileid)
user = User.objects.get(pk=user)
with language(locale, event.settings.region):
cols = get_voucher_import_columns(event)
data = _validate(cf, charset, cols, settings)
# 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 = []
lock_seats = []
for i, record in enumerate(data):
try:
voucher = Voucher(event=event)
vouchers.append(voucher)
if not record.get("code"):
raise ValidationError(_('A voucher cannot be created without a code.'))
Voucher.clean_item_properties(
record,
event,
record.get('quota'),
record.get('item'),
record.get('variation'),
block_quota=record.get('block_quota')
)
Voucher.clean_subevent(record, event)
Voucher.clean_max_usages(record, 0)
for c in cols:
c.assign(record.get(c.identifier), voucher)
if voucher.seat is not None:
lock_seats.append(voucher.seat)
except (ValidationError, ImportError) as e:
raise DataImportError(
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
)
with transaction.atomic():
# We don't support quotas here, so we only need to lock if seats are in use
if lock_seats:
lock_objects(lock_seats, shared_lock_objects=[event])
for s in lock_seats:
if not s.is_available():
raise DataImportError(
_('The seat you selected has already been taken. Please select a different seat.'))
for v in vouchers:
v.save()
v.log_action(
'pretix.voucher.added',
user=user,
data={'source': 'import'}
)
for c in cols:
c.save(v)
cf.delete()
+47 -5
View File
@@ -199,6 +199,7 @@ error_messages = {
),
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
'addon_already_checked_in': gettext_lazy('You cannot remove the position %(addon)s since it has already been checked in.'),
'currency_XXX': gettext_lazy('Paid products not supported without a valid currency.'),
}
logger = logging.getLogger(__name__)
@@ -297,6 +298,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, valid_if_p
auth=auth,
data={
'expires': order.expires,
'force': force,
'state_change': was_expired
}
)
@@ -412,6 +414,11 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
email_subject, email_template, email_context,
'pretix.event.order.email.order_approved', user,
attach_tickets=True,
attach_ical=order.event.settings.mail_attach_ical and (
not order.event.settings.mail_attach_ical_paid_only or
order.total == Decimal('0.00') or
order.valid_if_pending
),
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
)
except SendMailException:
@@ -665,7 +672,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
deleted_positions.add(cp.pk)
cp.delete()
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))
for cp in sorted_positions:
cp._cached_quotas = list(cp.quotas)
@@ -876,6 +883,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
# After applying discounts, add-on positions might still have a reference to the *old* version of the
# parent position, which can screw up ordering later since the system sees inconsistent data.
by_id = {cp.pk: cp for cp in sorted_positions}
for cp in sorted_positions:
if cp.addon_to_id:
cp.addon_to = by_id[cp.addon_to_id]
new_total = sum(cp.price for cp in sorted_positions)
if old_total != new_total:
err = err or error_messages['price_changed']
@@ -884,7 +898,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
for cp in sorted_positions:
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
cp.save(update_fields=['expires'])
if err:
raise OrderError(err)
@@ -1045,7 +1059,11 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
is_free or
order.valid_if_pending
),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
@@ -1064,7 +1082,11 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
log_entry,
invoices=[],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
is_free or
order.valid_if_pending
),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
@@ -1109,6 +1131,9 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
id__in=position_ids, event=event
)
if shown_total is not None and Decimal(shown_total) > Decimal("0.00") and event.currency == "XXX":
raise OrderError(error_messages['currency_XXX'])
validate_order.send(
event,
payment_provider=payment_requests[0]['provider'] if payment_requests else None, # only for backwards compatibility
@@ -1144,7 +1169,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
positions = list(
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
)
positions.sort(key=lambda k: position_ids.index(k.pk))
positions.sort(key=lambda c: c.sort_key)
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
@@ -1988,6 +2013,20 @@ 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 self.order.sales_channel not in a.variation.sales_channels)
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or self.order.sales_channel not in item.sales_channels
)
if is_unavailable:
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
@@ -2105,6 +2144,9 @@ class OrderChangeManager:
)
def _check_paid_to_free(self):
if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"):
raise OrderError(error_messages['currency_XXX'])
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
if not self.order.fees.exists() and not self.order.positions.exists():
# The order is completely empty now, so we cancel it.
+2 -2
View File
@@ -514,7 +514,7 @@ def base_placeholders(sender, **kwargs):
ph.append(SimpleFunctionalTextPlaceholder(
"name_for_salutation", ["waiting_list_entry"],
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
_("Mr Doe"),
lambda event: concatenation_for_salutation(name_scheme['sample']),
))
ph.append(SimpleFunctionalTextPlaceholder(
"name", ["waiting_list_entry"],
@@ -524,7 +524,7 @@ def base_placeholders(sender, **kwargs):
ph.append(SimpleFunctionalTextPlaceholder(
"name_for_salutation", ["position_or_address"],
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
_("Mr Doe"),
lambda event: concatenation_for_salutation(name_scheme['sample']),
))
for f, l, w in name_scheme['fields']:
+2
View File
@@ -29,6 +29,7 @@ from django.conf import settings
from django.db import models
from django.db.models import (
Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When,
prefetch_related_objects,
)
from django.utils.timezone import now
@@ -446,6 +447,7 @@ class QuotaAvailability:
self.results[q] = Quota.AVAILABILITY_RESERVED, 0
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
prefetch_related_objects(quotas, "event", "event__organizer")
quotas = [
q for q in quotas
if not q.event.settings.waiting_list_auto_disable or q.event.settings.waiting_list_auto_disable.datetime(q.subevent or q.event) > now()
+21 -21
View File
@@ -64,7 +64,7 @@ from pretix.api.serializers.fields import (
ListMultipleChoiceField, UploadedFileField,
)
from pretix.api.serializers.i18n import I18nField, I18nURLField
from pretix.base.forms import I18nURLFormField
from pretix.base.forms import I18nMarkdownTextarea, I18nURLFormField
from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule
from pretix.base.reldate import (
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
@@ -602,7 +602,7 @@ DEFAULTS = {
'serializer_class': I18nField,
'form_kwargs': dict(
label=_("Invoice address explanation"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown above the invoice address form during checkout.")
)
@@ -801,7 +801,7 @@ DEFAULTS = {
'serializer_class': I18nField,
'form_kwargs': dict(
label=_("End of presale text"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown above the ticket shop once the designated sales timeframe for this event "
"is over. You can use it to describe other options to get a ticket, such as a box office.")
@@ -813,7 +813,7 @@ DEFAULTS = {
'form_class': I18nFormField,
'serializer_class': I18nField,
'form_kwargs': dict(
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {
'rows': 3,
}},
@@ -1459,7 +1459,7 @@ DEFAULTS = {
'serializer_class': I18nField,
'form_kwargs': dict(
label=_("Phone number explanation"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.")
)
@@ -1876,7 +1876,7 @@ DEFAULTS = {
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Voluntary lower refund explanation"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown in between the explanation of how the refunds work and the slider "
"which your customers can use to choose the amount they would like to receive. You can use it "
@@ -1958,7 +1958,7 @@ DEFAULTS = {
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Terms of cancellation"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown when cancellation is allowed for a paid order. Leave empty if you "
"want pretix to automatically generate the terms of cancellation based on your settings.")
@@ -1971,7 +1971,7 @@ DEFAULTS = {
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Terms of cancellation"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown when cancellation is allowed for an unpaid or free order. Leave empty "
"if you want pretix to automatically generate the terms of cancellation based on your settings.")
@@ -2060,7 +2060,7 @@ DEFAULTS = {
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Event description"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_(
"You can use this to share information with your attendees, such as travel information or the link to a digital event. "
"If you keep it empty, we will put a link to the event shop, the admission time, and your organizer name in there. "
@@ -2991,7 +2991,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Frontpage text"),
widget=I18nTextarea
widget=I18nMarkdownTextarea,
)
},
'event_info_text': {
@@ -3013,7 +3013,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Banner text (top)"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown above every page of your shop. Please only use this for "
"very important messages.")
@@ -3026,7 +3026,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Banner text (bottom)"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown below every page of your shop. Please only use this for "
"very important messages.")
@@ -3039,7 +3039,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Voucher explanation"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain "
"how to obtain a voucher code.")
@@ -3052,7 +3052,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Attendee data explanation"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain "
"why you need information from them.")
@@ -3068,7 +3068,7 @@ Your {organizer} team""")) # noqa: W291
help_text=_("This message will be shown after an order has been created successfully. It will be shown in additional "
"to the default text."),
widget_kwargs={'attrs': {'rows': '2'}},
widget=I18nTextarea
widget=I18nMarkdownTextarea,
)
},
'checkout_phone_helptext': {
@@ -3079,7 +3079,7 @@ Your {organizer} team""")) # noqa: W291
'form_kwargs': dict(
label=_("Help text of the phone number field"),
widget_kwargs={'attrs': {'rows': '2'}},
widget=I18nTextarea
widget=I18nMarkdownTextarea,
)
},
'checkout_email_helptext': {
@@ -3093,7 +3093,7 @@ Your {organizer} team""")) # noqa: W291
'form_kwargs': dict(
label=_("Help text of the email field"),
widget_kwargs={'attrs': {'rows': '2'}},
widget=I18nTextarea
widget=I18nMarkdownTextarea,
)
},
'order_import_settings': {
@@ -3223,7 +3223,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': I18nFormField,
'form_kwargs': dict(
label=_('Homepage text'),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_('This will be displayed on the organizer homepage.')
)
},
@@ -3280,7 +3280,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Dialog text"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
)
},
@@ -3295,7 +3295,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': I18nFormField,
'form_kwargs': dict(
label=_("Secondary dialog text"),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
)
},
@@ -3419,7 +3419,7 @@ def concatenation_for_salutation(d):
salutation = pgettext("person_name_salutation", salutation)
given_name = None
return " ".join(filter(None, (salutation, title, given_name, family_name)))
return " ".join(str(p) for p in filter(None, (salutation, title, given_name, family_name)))
def get_name_parts_localized(name_parts, key):
+9
View File
@@ -785,6 +785,15 @@ to define additional columns that can be read during import. You are expected to
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
voucher_import_columns = EventPluginSignal()
"""
This signal is sent out if the user performs an import of vouchers from an external source. You can use this
to define additional columns that can be read during import. You are expected to return a list of instances of
``ImportColumn`` subclasses.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
validate_event_settings = EventPluginSignal()
"""
Arguments: ``settings_dict``
@@ -1,7 +1,7 @@
{% load thumb %}
{% if widget.is_initial %}{{ widget.initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}">
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}{% if widget.value.is_img %}<br><a href="{{ widget.value.url }}" data-lightbox="{{ widget.name }}"><img src="{{ widget.value|thumb:"200x100" }}" /></a>{% endif %}<br>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}{% if widget.value.is_img %}<br><a href="{{ widget.value.url }}" data-lightbox="{{ widget.name }}"><img src="{{ widget.value.name|thumb:"200x100" }}" /></a>{% endif %}<br>
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% if widget.cachedfile %}<input type="hidden" name="{{ widget.hidden_name }}" value="{{ widget.cachedfile.id }}">{% endif %}
+12
View File
@@ -159,6 +159,18 @@ def timeline_for_event(event, subevent=None):
})
))
rd = event.settings.get('waiting_list_auto_disable', as_type=RelativeDateWrapper)
if rd and event.settings.waiting_list_enabled:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=rd.datetime(ev),
description=pgettext_lazy('timeline', 'Waiting list is disabled'),
edit_url=reverse('control:event.settings', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
}) + '#waiting-list-open'
))
if not event.has_subevents:
days = event.settings.get('mail_days_download_reminder', as_type=int)
if days is not None and event.settings.ticket_download:
+30 -28
View File
@@ -55,12 +55,14 @@ from django.utils.timezone import get_current_timezone_name
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from django_countries.fields import LazyTypedChoiceField
from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput,
)
from pytz import common_timezones
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.forms import (
I18nMarkdownTextarea, I18nModelForm, PlaceholderValidator, SettingsForm,
)
from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
@@ -988,7 +990,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_signature = I18nFormField(
label=_("Signature"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_("This will be attached to every email. Available placeholders: {event}"),
validators=[PlaceholderValidator(['{event}'])],
widget_kwargs={'attrs': {
@@ -1011,7 +1013,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_placed = I18nFormField(
label=_("Text sent to order contact address"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_send_order_placed_attendee = forms.BooleanField(
label=_("Send an email to attendees"),
@@ -1027,7 +1029,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_placed_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_order_paid = I18nFormField(
@@ -1038,7 +1040,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_paid = I18nFormField(
label=_("Text sent to order contact address"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_send_order_paid_attendee = forms.BooleanField(
label=_("Send an email to attendees"),
@@ -1054,7 +1056,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_paid_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_order_free = I18nFormField(
@@ -1065,7 +1067,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_free = I18nFormField(
label=_("Text sent to order contact address"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_send_order_free_attendee = forms.BooleanField(
label=_("Send an email to attendees"),
@@ -1081,7 +1083,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_free_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_order_changed = I18nFormField(
@@ -1092,7 +1094,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_changed = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_resend_link = I18nFormField(
label=_("Subject (sent by admin)"),
@@ -1107,7 +1109,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_resend_link = I18nFormField(
label=_("Text (sent by admin)"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_resend_all_links = I18nFormField(
label=_("Subject (requested by user)"),
@@ -1117,7 +1119,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_resend_all_links = I18nFormField(
label=_("Text (requested by user)"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_days_order_expire_warning = forms.IntegerField(
label=_("Number of days"),
@@ -1129,7 +1131,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_expire_warning = I18nFormField(
label=_("Text (if order will expire automatically)"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_order_expire_warning = I18nFormField(
label=_("Subject (if order will expire automatically)"),
@@ -1139,7 +1141,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_pending_warning = I18nFormField(
label=_("Text (if order will not expire automatically)"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_order_pending_warning = I18nFormField(
label=_("Subject (if order will not expire automatically)"),
@@ -1154,7 +1156,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_incomplete_payment = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_("This email only applies to payment methods that can receive incomplete payments, "
"such as bank transfer."),
)
@@ -1166,7 +1168,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_payment_failed = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_waiting_list = I18nFormField(
label=_("Subject"),
@@ -1176,7 +1178,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_waiting_list = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_order_canceled = I18nFormField(
label=_("Subject"),
@@ -1186,12 +1188,12 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_canceled = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_text_order_custom_mail = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_download_reminder = I18nFormField(
label=_("Subject sent to order contact address"),
@@ -1201,7 +1203,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_download_reminder = I18nFormField(
label=_("Text sent to order contact address"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_send_download_reminder_attendee = forms.BooleanField(
label=_("Send an email to attendees"),
@@ -1217,7 +1219,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_download_reminder_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_days_download_reminder = forms.IntegerField(
label=_("Number of days"),
@@ -1234,7 +1236,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_placed_require_approval = I18nFormField(
label=_("Text for received order"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_order_approved = I18nFormField(
label=_("Subject for approved order"),
@@ -1244,7 +1246,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_approved = I18nFormField(
label=_("Text for approved order"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order "
"template from below instead."),
)
@@ -1262,7 +1264,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_approved_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order "
"template from below instead."),
)
@@ -1274,7 +1276,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_approved_free = I18nFormField(
label=_("Text for approved free order"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_("This will only be sent out for free orders. Non-free orders will receive the non-free order "
"template from above instead."),
)
@@ -1292,7 +1294,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_approved_free_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_("This will only be sent out for free orders. Non-free orders will receive the non-free order "
"template from above instead."),
)
@@ -1304,7 +1306,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_denied = I18nFormField(
label=_("Text for denied order"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
base_context = {
'mail_text_order_placed': ['event', 'order', 'payments'],
@@ -1737,7 +1739,7 @@ class ItemMetaPropertyForm(forms.ModelForm):
class ConfirmTextForm(I18nForm):
text = I18nFormField(
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
)
+1 -1
View File
@@ -1231,7 +1231,7 @@ class SubEventFilterForm(FilterForm):
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query) | Q(comment__icontains=query)
)
if fdata.get('date_until'):
+6 -4
View File
@@ -36,10 +36,12 @@ from collections import OrderedDict
from django import forms
from django.utils.translation import gettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.forms import I18nFormField, I18nTextInput
from pretix import settings
from pretix.base.forms import SecretKeySettingsField, SettingsForm
from pretix.base.forms import (
I18nMarkdownTextarea, SecretKeySettingsField, SettingsForm,
)
from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import register_global_settings
@@ -67,12 +69,12 @@ class GlobalSettingsForm(SettingsForm):
help_text=_("Will be included as the link in the additional footer text.")
)),
('banner_message', I18nFormField(
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
required=False,
label=_("Global message banner"),
)),
('banner_message_detail', I18nFormField(
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
required=False,
label=_("Global message banner detail text"),
)),
+6 -1
View File
@@ -56,7 +56,7 @@ from django_scopes.forms import (
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nFormSet, I18nModelForm
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
@@ -82,6 +82,9 @@ class CategoryForm(I18nModelForm):
'description',
'is_addon'
]
widgets = {
'description': I18nMarkdownTextarea,
}
class QuestionForm(I18nModelForm):
@@ -188,6 +191,7 @@ class QuestionForm(I18nModelForm):
attrs={'class': 'scrolling-multiple-choice'}
),
'dependency_values': forms.SelectMultiple,
'help_text': I18nMarkdownTextarea,
}
field_classes = {
'valid_datetime_min': SplitDateTimeField,
@@ -823,6 +827,7 @@ class ItemUpdateForm(I18nModelForm):
'max_per_order': forms.widgets.NumberInput(attrs={'min': 0}),
'min_per_order': forms.widgets.NumberInput(attrs={'min': 0}),
'checkin_text': forms.TextInput(),
'description': I18nMarkdownTextarea,
}
@@ -22,10 +22,54 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from pretix.base.services.orderimport import get_all_columns
from pretix.base.modelimport_orders import get_order_import_columns
from pretix.base.modelimport_vouchers import get_voucher_import_columns
class ProcessForm(forms.Form):
def __init__(self, *args, **kwargs):
headers = kwargs.pop('headers')
initital = kwargs.pop('initial', {}) or {}
kwargs['initial'] = initital
columns = self.get_columns()
column_keys = {c.identifier for c in columns}
if not initital or all(k not in column_keys for k in initital.keys()):
for c in columns:
initital.setdefault(c.identifier, c.initial)
for h in headers:
if h == c.identifier or h == str(c.verbose_name):
initital[c.identifier] = 'csv:{}'.format(h)
break
super().__init__(*args, **kwargs)
header_choices = [
('csv:{}'.format(h), _('CSV column: "{name}"').format(name=h)) for h in headers
]
for c in columns:
choices = []
if c.default_value:
choices.append((c.default_value, c.default_label))
choices += header_choices
for k, v in c.static_choices():
choices.append(('static:{}'.format(k), v))
self.fields[c.identifier] = forms.ChoiceField(
label=str(c.verbose_name),
choices=choices,
widget=forms.Select(
attrs={'data-static': 'true'}
)
)
def get_columns(self):
raise NotImplementedError() # noqa
class OrdersProcessForm(ProcessForm):
orders = forms.ChoiceField(
label=_('Import mode'),
choices=(
@@ -46,29 +90,21 @@ class ProcessForm(forms.Form):
)
def __init__(self, *args, **kwargs):
headers = kwargs.pop('headers')
initital = kwargs.pop('initial', {})
self.event = kwargs.pop('event')
initital = kwargs.pop('initial', {})
initital['testmode'] = self.event.testmode
kwargs['initial'] = initital
super().__init__(*args, **kwargs)
header_choices = [
('csv:{}'.format(h), _('CSV column: "{name}"').format(name=h)) for h in headers
]
def get_columns(self):
return get_order_import_columns(self.event)
for c in get_all_columns(self.event):
choices = []
if c.default_value:
choices.append((c.default_value, c.default_label))
choices += header_choices
for k, v in c.static_choices():
choices.append(('static:{}'.format(k), v))
self.fields[c.identifier] = forms.ChoiceField(
label=str(c.verbose_name),
choices=choices,
widget=forms.Select(
attrs={'data-static': 'true'}
)
)
class VouchersProcessForm(ProcessForm):
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
def get_columns(self):
return get_voucher_import_columns(self.event)
+9 -6
View File
@@ -47,11 +47,14 @@ from django.utils.translation import (
gettext_lazy as _, gettext_noop, pgettext_lazy,
)
from django_scopes.forms import SafeModelChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.forms import I18nFormField, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.forms import (
I18nMarkdownTextarea, I18nModelForm, MarkdownTextarea,
PlaceholderValidator,
)
from pretix.base.forms.questions import WrappedPhoneNumberPrefixWidget
from pretix.base.forms.widgets import (
DatePickerWidget, SplitDateTimePickerWidget, format_placeholders_help_text,
@@ -699,7 +702,7 @@ class OrderMailForm(forms.Form):
self.fields['message'] = forms.CharField(
label=_("Message"),
required=True,
widget=forms.Textarea,
widget=MarkdownTextarea,
initial=order.event.settings.mail_text_order_custom_mail.localize(order.locale),
)
self.fields['attach_invoices'].queryset = order.invoices.all()
@@ -716,7 +719,7 @@ class OrderPositionMailForm(OrderMailForm):
self.fields['message'] = forms.CharField(
label=_("Message"),
required=True,
widget=forms.Textarea,
widget=MarkdownTextarea,
initial=self.order.event.settings.mail_text_order_custom_mail.localize(self.order.locale),
)
self._set_field_placeholders('message', ['event', 'order', 'position'])
@@ -883,7 +886,7 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form):
)
self.fields['send_message'] = I18nFormField(
label=_('Message'),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
required=True,
widget_kwargs={'attrs': {'data-display-dependency': '#id_send'}},
locales=self.event.settings.get('locales'),
@@ -910,7 +913,7 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form):
)
self.fields['send_waitinglist_message'] = I18nFormField(
label=_('Message'),
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
required=True,
locales=self.event.settings.get('locales'),
widget_kwargs={'attrs': {'data-display-dependency': '#id_send_waitinglist'}},
+9 -7
View File
@@ -48,7 +48,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput,
)
from phonenumber_field.formfields import PhoneNumberField
from pytz import common_timezones
@@ -56,7 +56,9 @@ from pytz import common_timezones
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.customersso.oidc import oidc_validate_and_complete_config
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.forms import (
I18nMarkdownTextarea, I18nModelForm, PlaceholderValidator, SettingsForm,
)
from pretix.base.forms.questions import (
NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale,
get_phone_prefix,
@@ -257,7 +259,7 @@ class TeamForm(forms.ModelForm):
class Meta:
model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
fields = ['name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards', 'can_manage_customers',
'can_manage_reusable_media',
@@ -524,7 +526,7 @@ class MailSettingsForm(SettingsForm):
mail_text_signature = I18nFormField(
label=_("Signature"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
help_text=_("This will be attached to every email."),
validators=[PlaceholderValidator([])],
widget_kwargs={'attrs': {
@@ -543,7 +545,7 @@ class MailSettingsForm(SettingsForm):
mail_text_customer_registration = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_customer_email_change = I18nFormField(
label=_("Subject"),
@@ -553,7 +555,7 @@ class MailSettingsForm(SettingsForm):
mail_text_customer_email_change = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
mail_subject_customer_reset = I18nFormField(
label=_("Subject"),
@@ -563,7 +565,7 @@ class MailSettingsForm(SettingsForm):
mail_text_customer_reset = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
widget=I18nMarkdownTextarea,
)
base_context = {
+3 -1
View File
@@ -68,6 +68,7 @@ class SubEventForm(I18nModelForm):
'presale_end',
'location',
'frontpage_text',
'comment',
'geo_lat',
'geo_lon',
]
@@ -136,7 +137,7 @@ class SubEventBulkEditForm(I18nModelForm):
self.fields[k].widget.attrs['placeholder'] = ''
self.fields[k].one_required = False
for k in ('geo_lat', 'geo_lon'):
for k in ('geo_lat', 'geo_lon', 'comment'):
# scalar fields
if k in self.mixed_values:
self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values'))
@@ -166,6 +167,7 @@ class SubEventBulkEditForm(I18nModelForm):
'name',
'location',
'frontpage_text',
'comment',
'geo_lat',
'geo_lon',
'is_public',
+4 -2
View File
@@ -45,7 +45,9 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.forms import (
I18nModelForm, MarkdownTextarea, PlaceholderValidator,
)
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.models import Item, Voucher
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
@@ -271,7 +273,7 @@ class VoucherBulkForm(VoucherForm):
)
send_message = forms.CharField(
label=_("Message"),
widget=forms.Textarea(attrs={'data-display-dependency': '#id_send'}),
widget=MarkdownTextarea(attrs={'data-display-dependency': '#id_send'}),
required=False,
initial=_('Hello,\n\n'
'with this email, we\'re sending you one or more vouchers for {event}:\n\n{voucher_list}\n\n'
+3
View File
@@ -455,9 +455,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.control.auth.user.created': _('The user has been created.'),
'pretix.control.auth.user.new_source': _('A first login using {agent_type} on {os_type} from {country} has '
'been detected.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'),
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
+10 -8
View File
@@ -48,7 +48,8 @@ from pretix.base.models import Event, Organizer
from pretix.base.models.auth import SuperuserPermissionSet, User
from pretix.helpers.http import redirect_to_url
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
SessionReauthRequired, assert_session_valid,
)
@@ -67,6 +68,7 @@ class PermissionMiddleware:
"auth.forgot.recover",
"auth.invite",
"user.settings.notifications.off",
"auth.bad_origin_report",
)
EXCEPTIONS_FORCED_PW_CHANGE = (
@@ -83,6 +85,7 @@ class PermissionMiddleware:
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.webauthn",
"user.settings.2fa.delete",
"user.settings.2fa.leaveteams",
"auth.logout",
"user.reauth"
)
@@ -134,13 +137,12 @@ class PermissionMiddleware:
except SessionReauthRequired:
if url_name not in ('user.reauth', 'auth.logout'):
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
if request.user.needs_password_change and url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
and url_name not in self.EXCEPTIONS_2FA and not request.user.needs_password_change:
return redirect_to_url(reverse('control:user.settings.2fa'))
except SessionPasswordChangeRequired:
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
except Session2FASetupRequired:
if url_name not in self.EXCEPTIONS_2FA:
return redirect_to_url(reverse('control:user.settings.2fa'))
if 'event' in url.kwargs and 'organizer' in url.kwargs:
if url.kwargs['organizer'] == '-' and url.kwargs['event'] == '-':
@@ -58,5 +58,6 @@
{{ poweredby }} {# removing or hiding this might be in violation of pretix' license #}
</footer>
</div>
<script type="text/javascript" src="{% static "pretixcontrol/js/auth.js" %}"></script>
</body>
</html>
@@ -46,5 +46,7 @@
{% endif %}
</div>
</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 %}
@@ -28,7 +28,15 @@
<dt>{% trans "Receipt number" context "terminal_zvt" %}</dt>
<dd>{{ payment_info.payment_data.receiptNumber }}</dd>
<dt>{% trans "Card type" context "terminal_zvt" %}</dt>
<dd>{{ payment_info.payment_data.cardName|default_if_none:payment_info.payment_data.cardType }}</dd>
<dd>
{% if payment_info.payment_data.cardName %}
{{ payment_info.payment_data.cardName }}
{% elif payment_info.payment_data.cardType %}
{{ payment_info.payment_data.cardType }}
{% else %}
{% trans "Unknown" %}
{% endif %}
</dd>
<dt>{% trans "Card expiration" context "terminal_zvt" %}</dt>
<dd>{{ payment_info.payment_data.expiry }}</dd>
{% endif %}
@@ -0,0 +1,13 @@
{% load i18n %}{% blocktrans with url=url|safe os=source.os_type agent=source.agent_type %}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 }}.
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:
{{ url }}
Best regards,
Your {{ instance }} team
{% endblocktrans %}
@@ -335,7 +335,7 @@
{% bootstrap_field sform.max_items_per_order layout="control" %}
{% bootstrap_field sform.redirect_to_checkout_directly layout="control" %}
</fieldset>
<fieldset>
<fieldset id="waiting-list">
<legend>{% trans "Waiting list" %}</legend>
<div class="alert alert-info">
{% blocktrans trimmed %}
@@ -19,8 +19,8 @@
section of your website:
{% endblocktrans %}
</p>
<pre>&lt;link rel="stylesheet" type="text/css" href="{% abseventurl request.event "presale:event.widget.css" %}"&gt;
&lt;script type="text/javascript" src="{{ urlprefix }}{% url "presale:widget.js" lang=form.cleaned_data.language %}" async&gt;&lt;/script&gt;</pre>
<pre>&lt;link rel="stylesheet" type="text/css" href="{% abseventurl request.event "presale:event.widget.css" %}" crossorigin&gt;
&lt;script type="text/javascript" src="{{ urlprefix }}{% url "presale:widget.js" lang=form.cleaned_data.language %}" async crossorigin&gt;&lt;/script&gt;</pre>
<p>
{% blocktrans trimmed %}
Then, copy the following code to the place of your website where you want the widget to show up:
@@ -18,6 +18,7 @@
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
{% bootstrap_field form.require_2fa layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Organizer permissions" %}</legend>
@@ -70,6 +70,7 @@
</div>
{% bootstrap_field form.frontpage_text layout="bulkedit" %}
{% bootstrap_field form.is_public layout="bulkedit" %}
{% bootstrap_field form.comment layout="bulkedit" %}
{% if meta_forms %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
@@ -29,6 +29,7 @@
{% bootstrap_field form.date_admission layout="control" %}
{% bootstrap_field form.frontpage_text layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% bootstrap_field form.comment layout="control" %}
{% if meta_forms %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
@@ -138,6 +138,9 @@
<small class="text-muted">&middot; {{ k }}: {{ v }}</small>
{% endif %}
{% endfor %}
{% if s.comment %}
<br>{{ s.comment|linebreaksbr }}
{% endif %}
</small>
</td>
<td>
@@ -170,6 +173,8 @@
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:event.orders" organizer=request.event.organizer.slug event=request.event.slug %}?subevent={{ s.id }}" class="btn btn-default btn-sm" title="{% trans "Show orders" %}"><i class="fa fa-shopping-cart" aria-hidden="true"></i></a>
<a href="{% url "control:event.subevent" organizer=request.event.organizer.slug event=request.event.slug subevent=s.id %}?returnto={{ request.GET.urlencode|urlencode }}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<div class="btn-group {% if forloop.revcounter0 < 2 %}dropup{% endif %}">
<button type="button" class="btn btn-default btn-sm dropdown-toggle"
@@ -0,0 +1,30 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Two-factor authentication" %}{% endblock %}
{% block content %}
<h1>{% trans "Leave teams that require two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>
<strong>{% trans "Do you really want to leave the following teams?" %}</strong>
</p>
<ul>
{% for t in obligatory_teams %}
<li>
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
Team "{{ team }}" of organizer "{{ organizer }}"
{% endblocktrans %}
</li>
{% endfor %}
</ul>
<div class="form-group submit-group">
<a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Leave" %}
</button>
</div>
</form>
{% endblock %}
@@ -11,23 +11,55 @@
smartphone or a hardware token generator and that changes on a regular basis.
{% endblocktrans %}
</p>
{% if settings.PRETIX_OBLIGATORY_2FA %}
{% if obligatory and not user.require_2fa %}
<div class="panel panel-warning">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Obligatory usage of two-factor authentication" %}</h3>
<h3 class="panel-title">
<span class="fa fa-warning"></span>
{% trans "Obligatory usage of two-factor authentication" %}
</h3>
</div>
<div class="panel-body">
{% if obligatory == "system" %}
<p>
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
</p>
{% elif obligatory == "staff" %}
<p>
<strong>{% trans "As an administrator, you need to use two-factor authentication." %}</strong>
</p>
{% elif obligatory == "team" %}
<p>
<strong>{% trans "You are part of one or more organizer teams that require you to use two-factor authentication." %}</strong>
</p>
<ul>
{% for t in obligatory_teams %}
<li>
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
Team "{{ team }}" of organizer "{{ organizer }}"
{% endblocktrans %}
</li>
{% endfor %}
</ul>
{% endif %}
<p>
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
{% if not devices %}
{% trans "Please set up at least one device below." %}
{% elif not user.require_2fa %}
{% trans "Please activate two-factor authentication using the button below." %}
{% endif %}
{% if obligatory == "team" %}
<a href="{% url "control:user.settings.2fa.leaveteams" %}">
{% blocktrans trimmed count count=obligatory_teams|length %}
Leave team instead
{% plural %}
Leave {{ count }} teams instead
{% endblocktrans %}
</a>
{% endif %}
</p>
{% if not devices %}
<p>{% trans "Please set up at least one device below." %}</p>
{% elif not user.require_2fa %}
<p>{% trans "Please activate two-factor authentication using the button below." %}</p>
{% endif %}
</div>
</div>
{% endif %}
{% if user.require_2fa %}
<div class="panel panel-success">
@@ -35,7 +67,18 @@
<h3 class="panel-title">{% trans "Two-factor status" %}</h3>
</div>
<div class="panel-body">
{% if not settings.PRETIX_OBLIGATORY_2FA %}
{% if obligatory %}
<button disabled class="btn btn-primary pull-right flip" data-toggle="tooltip"
title="{% spaceless %}{% if obligatory == "system" %}
{% trans "This system enforces the usage of two-factor authentication!" %}
{% elif obligatory == "staff" %}
{% trans "As an administrator, you need to use two-factor authentication." %}
{% elif obligatory == "team" %}
{% trans "You are part of one or more organizer teams that require you to use two-factor authentication." %}
{% endif %}{% endspaceless %}">
{% trans "Disable" %}
</button>
{% else %}
<a href="{% url "control:user.settings.2fa.disable" %}" class="btn btn-primary pull-right flip">
{% trans "Disable" %}
</a>
@@ -73,7 +116,7 @@
{% for d in devices %}
<li class="list-group-item">
<a class="btn btn-danger btn-xs pull-right flip"
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
Delete
</a>
{% if d.devicetype == "totp" %}
@@ -11,6 +11,12 @@
<button class="btn btn-default">{% trans "Send password reset email" %}</button>
</form>
{% endif %}
{% if user.require_2fa %}
<form action="{% url "control:users.emergencytoken" id=user.pk %}" method="post" class="form-inline helper-display-inline">
{% csrf_token %}
<button class="btn btn-default">{% trans "Generate 2FA emergency token" %}</button>
</form>
{% endif %}
<form action="{% url "control:users.impersonate" id=user.pk %}" method="post" class="form-inline helper-display-inline">
{% csrf_token %}
<button class="btn btn-default">{% trans "Impersonate user" %}</button>
@@ -0,0 +1,61 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% load getitem %}
{% load bootstrap3 %}
{% block title %}{% trans "Import vouchers" %}{% endblock %}
{% block content %}
<h1>{% trans "Import vouchers" %}</h1>
<form action="" method="post" class="form-horizontal" data-asynctask data-asynctask-long>
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Data preview" %}</h3>
</div>
<div class="table-responsive panel-body">
<table class="table table-condensed">
<thead>
<tr>
{% for fn in parsed.fieldnames %}
<th>{{ fn }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for r in sample_rows %}
<tr>
{% for fn in parsed.fieldnames %}
<td>{{ r|getitem:fn }}</td>
{% endfor %}
</tr>
{% endfor %}
<tr>
<td class="text-center" colspan="{{ parsed.fieldnames|length }}">
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Import settings" %}</h3>
</div>
<div class="panel-body">
{% bootstrap_form_errors form %}
{% bootstrap_form form layout="horizontal" %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
The import will be performed regardless of your quotas, so it will be possible to overbook your event using this option.
{% endblocktrans %}
</div>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Perform import" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Import vouchers" %}{% endblock %}
{% block content %}
<h1>{% trans "Import vouchers" %}</h1>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
</div>
<div class="panel-body">
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{% blocktrans trimmed %}
The uploaded file should be a CSV file with a header row. You will be able to assign the
meanings of the different columns in the next step.
{% endblocktrans %}
</p>
<div class="form-group">
<label for="file">{% trans "Import file" %}: </label> <input id="file" type="file" name="file"/>
</div>
<div class="form-group">
<label for="file">{% trans "Character set" %}: </label>
<select name="charset" class="form-control">
<option>{% trans "Detect automatically" %}</option>
{% for e in encodings %}
<option value="{{ e }}">{{ e }}</option>
{% endfor %}
</select>
</div>
<div class="clearfix"></div>
<button class="btn btn-primary pull-right flip" type="submit">
<span class="icon icon-upload"></span> {% trans "Start import" %}
</button>
</form>
</div>
</div>
{% endblock %}
@@ -77,6 +77,8 @@
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create multiple new vouchers" %}</a>
<a href="{% url "control:event.vouchers.import" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-lg"><i class="fa fa-upload"></i> {% trans "Import vouchers" %}</a>
{% endif %}
</div>
{% else %}
@@ -87,6 +89,9 @@
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i>
{% trans "Create multiple new vouchers" %}</a>
<a href="{% url "control:event.vouchers.import" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-upload"></i>
{% trans "Import vouchers" %}</a>
{% endif %}
<a href="?{% url_replace request "download" "yes" %}"
class="btn btn-default"><i class="fa fa-download"></i>
+10 -5
View File
@@ -38,13 +38,14 @@ from django.views.generic.base import RedirectView
from pretix.control.views import (
auth, checkin, dashboards, discounts, event, geo, global_settings, item,
main, oauth, orderimport, orders, organizer, pdf, search, shredder,
main, modelimport, oauth, orders, organizer, pdf, search, shredder,
subevents, typeahead, user, users, vouchers, waitinglist,
)
urlpatterns = [
re_path(r'^logout$', auth.logout, name='auth.logout'),
re_path(r'^login$', auth.login, name='auth.login'),
re_path(r'^login/bad_origin$', auth.bad_origin_report, name='auth.bad_origin_report'),
re_path(r'^login/2fa$', auth.Login2FAView.as_view(), name='auth.login.2fa'),
re_path(r'^register$', auth.register, name='auth.register'),
re_path(r'^invite/(?P<token>[a-zA-Z0-9]+)$', auth.invite, name='auth.invite'),
@@ -71,8 +72,9 @@ urlpatterns = [
re_path(r'^users/impersonate/stop', users.UserImpersonateStopView.as_view(), name='users.impersonate.stop'),
re_path(r'^users/(?P<id>\d+)/$', users.UserEditView.as_view(), name='users.edit'),
re_path(r'^users/(?P<id>\d+)/reset$', users.UserResetView.as_view(), name='users.reset'),
re_path(r'^users/(?P<id>\d+)/impersonate', users.UserImpersonateView.as_view(), name='users.impersonate'),
re_path(r'^users/(?P<id>\d+)/anonymize', users.UserAnonymizeView.as_view(), name='users.anonymize'),
re_path(r'^users/(?P<id>\d+)/impersonate$', users.UserImpersonateView.as_view(), name='users.impersonate'),
re_path(r'^users/(?P<id>\d+)/anonymize$', users.UserAnonymizeView.as_view(), name='users.anonymize'),
re_path(r'^users/(?P<id>\d+)/emergencytoken$', users.UserEmergencyTokenView.as_view(), name='users.emergencytoken'),
re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
re_path(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
re_path(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
@@ -94,6 +96,7 @@ urlpatterns = [
re_path(r'^settings/oauth/apps/(?P<pk>\d+)/roll$', oauth.OAuthApplicationRollView.as_view(),
name='user.settings.oauth.app.roll'),
re_path(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
re_path(r'^settings/2fa/leaveteams$', user.User2FALeaveTeamsView.as_view(), name='user.settings.2fa.leaveteams'),
re_path(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
re_path(r'^settings/2fa/enable', user.User2FAEnableView.as_view(), name='user.settings.2fa.enable'),
re_path(r'^settings/2fa/disable', user.User2FADisableView.as_view(), name='user.settings.2fa.disable'),
@@ -346,6 +349,8 @@ 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/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(),
name='event.order.transition'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/resend$', orders.OrderResendLink.as_view(),
@@ -412,8 +417,8 @@ urlpatterns = [
re_path(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
name='event.invoice.download'),
re_path(r'^orders/overview/$', orders.OverView.as_view(), name='event.orders.overview'),
re_path(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'),
re_path(r'^orders/import/(?P<file>[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'),
re_path(r'^orders/import/$', modelimport.OrderImportView.as_view(), name='event.orders.import'),
re_path(r'^orders/import/(?P<file>[^/]+)/$', modelimport.OrderProcessView.as_view(), name='event.orders.import.process'),
re_path(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
re_path(r'^orders/export/(?P<pk>[^/]+)/run$', orders.RunScheduledExportView.as_view(), name='event.orders.export.scheduled.run'),
re_path(r'^orders/export/(?P<pk>[^/]+)/delete$', orders.DeleteScheduledExportView.as_view(), name='event.orders.export.scheduled.delete'),
+66 -4
View File
@@ -36,7 +36,7 @@ import base64
import json
import logging
import time
from urllib.parse import quote, urlparse
from urllib.parse import quote, urljoin, urlparse
import webauthn
from django.conf import settings
@@ -47,11 +47,14 @@ from django.contrib.auth import (
from django.contrib.auth.tokens import default_token_generator
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from django_otp import match_token
from webauthn.helpers import generate_challenge
@@ -60,9 +63,11 @@ from pretix.base.auth import get_auth_backends
from pretix.base.forms.auth import (
LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm,
)
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.base.services.mail import SendMailException
from pretix.helpers.http import redirect_to_url
from pretix.helpers.http import get_client_ip, redirect_to_url
from pretix.helpers.security import handle_login_source
logger = logging.getLogger(__name__)
@@ -77,6 +82,7 @@ def process_login(request, user, keep_logged_in):
request.session['pretix_auth_long_session'] = settings.PRETIX_LONG_SESSIONS and keep_logged_in
next_url = get_auth_backends()[user.auth_backend].get_next_url(request)
if user.require_2fa:
logger.info(f"Backend login redirected to 2FA for user {user.pk}.")
request.session['pretix_auth_2fa_user'] = user.pk
request.session['pretix_auth_2fa_time'] = str(int(time.time()))
twofa_url = reverse('control:auth.login.2fa')
@@ -84,6 +90,9 @@ def process_login(request, user, keep_logged_in):
twofa_url += '?next=' + quote(next_url)
return redirect_to_url(twofa_url)
else:
logger.info(f"Backend login successful for user {user.pk}.")
pretix_successful_logins.inc(1)
handle_login_source(user, request)
auth_login(request, user)
request.session['pretix_auth_login_time'] = int(time.time())
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts=None):
@@ -105,6 +114,9 @@ def login(request):
return process_login(request, u, False)
b.url = b.authentication_url(request)
# Login should only happen on configured main domain
good_origin = urlparse(settings.SITE_URL).scheme + '://' + urlparse(settings.SITE_URL).hostname
backend = backenddict.get(request.GET.get('backend', 'native'), backends[0])
if not backend.visible:
backend = [b for b in backends if b.visible][0]
@@ -115,7 +127,23 @@ def login(request):
return redirect(reverse('control:index'))
if request.method == 'POST':
form = LoginForm(backend=backend, data=request.POST, request=request)
if form.is_valid() and form.user_cache and form.user_cache.auth_backend == backend.identifier:
is_valid = form.is_valid() and form.user_cache and form.user_cache.auth_backend == backend.identifier
if form.cleaned_data.get("origin"):
form_origin = form.cleaned_data.get("origin")
if good_origin != form_origin:
logger.warning(
f"Received login form submission with unexpected origin value. "
f"Origin sent from JavaScript: {form_origin} / "
f"Expected origin from configuration: {good_origin} / "
f"HTTP Host header: {request.headers.get('Host')} / "
f"HTTP origin header: {request.headers.get('Origin')} / "
f"HTTP referer header: {request.headers.get('Referer')} / "
f"IP address: {get_client_ip(request)} / "
f"Login result: {is_valid}"
)
if is_valid:
return process_login(request, form.user_cache, form.cleaned_data.get('keep_logged_in', False))
else:
form = LoginForm(backend=backend, request=request)
@@ -124,9 +152,35 @@ def login(request):
ctx['can_reset'] = settings.PRETIX_PASSWORD_RESET
ctx['backends'] = backends
ctx['backend'] = backend
ctx['good_origin'] = good_origin[::-1] # minimal obfuscation against standard link rewriting
ctx['bad_origin_report_url'] = urljoin(
# as an additional safeguard always use SITE_URL, not anything derived from request
settings.SITE_URL,
reverse('control:auth.bad_origin_report')
)[::-1]
return render(request, 'pretixcontrol/auth/login.html', ctx)
@csrf_exempt
@require_http_methods(["POST"])
def bad_origin_report(request):
good_origin = urlparse(settings.SITE_URL).scheme + '://' + urlparse(settings.SITE_URL).hostname
form_origin = request.POST.get("origin")
if good_origin != form_origin:
logger.warning(
f"Received report of unexpected origin value. "
f"Origin sent from JavaScript: {form_origin} / "
f"Expected origin from configuration: {good_origin} / "
f"HTTP Host header: {request.headers.get('Host')} / "
f"HTTP origin header: {request.headers.get('Origin')} / "
f"HTTP referer header: {request.headers.get('Referer')} / "
f"IP address: {get_client_ip(request)}"
)
resp = HttpResponse()
resp['Access-Control-Allow-Origin'] = '*'
return resp
def logout(request):
"""
Log the user out of the current session, then redirect to login page.
@@ -284,7 +338,7 @@ class Forgot(TemplateView):
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
except User.DoesNotExist:
logger.warning('Password reset for unregistered e-mail \"' + email + '\" requested.')
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
except SendMailException:
logger.exception('Sending password reset e-mail to \"' + email + '\" failed.')
@@ -411,6 +465,7 @@ class Login2FAView(TemplateView):
fail = True
logintime = int(request.session.get('pretix_auth_2fa_time', '1'))
if time.time() - logintime > 300:
pretix_failed_logins.inc(1, reason="2fa-timeout")
fail = True
if fail:
messages.error(request, _('Please try again.'))
@@ -443,6 +498,7 @@ class Login2FAView(TemplateView):
)
sign_count = webauthn_assertion_response.new_sign_count
if sign_count < credential_current_sign_count:
pretix_failed_logins.inc(1, reason="webauthn-replay")
raise Exception("Possible replay attack, sign count not higher")
except Exception:
if isinstance(d, U2FDevice):
@@ -460,11 +516,13 @@ class Login2FAView(TemplateView):
if webauthn_assertion_response.new_sign_count < 1:
raise Exception("Possible replay attack, sign count set")
except Exception:
pretix_failed_logins.inc(1, reason="u2f")
logger.exception('U2F login failed')
else:
valid = True
break
else:
pretix_failed_logins.inc(1, reason="webauthn")
logger.exception('Webauthn login failed')
else:
if isinstance(d, WebAuthnDevice):
@@ -476,6 +534,9 @@ class Login2FAView(TemplateView):
valid = match_token(self.user, token)
if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
pretix_successful_logins.inc(1)
handle_login_source(self.user, request)
auth_login(request, self.user)
request.session['pretix_auth_login_time'] = int(time.time())
del request.session['pretix_auth_2fa_user']
@@ -484,6 +545,7 @@ class Login2FAView(TemplateView):
return redirect_to_url(request.GET.get("next"))
return redirect('control:index')
else:
pretix_failed_logins.inc(1, reason="2fa")
messages.error(request, _('Invalid code, please try again.'))
return redirect('control:auth.login.2fa')
@@ -46,9 +46,13 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView, TemplateView
from pretix.base.models import CachedFile
from pretix.base.services.orderimport import import_orders, parse_csv
from pretix.base.services.modelimport import (
import_orders, import_vouchers, parse_csv,
)
from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.orderimport import ProcessForm
from pretix.control.forms.modelimport import (
OrdersProcessForm, VouchersProcessForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.http import redirect_to_url
@@ -64,28 +68,16 @@ ENCODINGS = (
)
class ImportView(EventPermissionRequiredMixin, TemplateView):
template_name = 'pretixcontrol/orders/import_start.html'
permission = 'can_change_orders'
class BaseImportView(TemplateView):
def post(self, request, *args, **kwargs):
if 'file' not in request.FILES:
return redirect_to_url(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return redirect_to_url(request.path)
if not request.FILES['file'].name.lower().endswith('.csv'):
messages.error(request, _('Please only upload CSV files.'))
return redirect_to_url(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return redirect_to_url(request.path)
if request.FILES['file'].size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
messages.error(request, _('Please do not upload files larger than 10 MB.'))
return redirect_to_url(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return redirect_to_url(request.path)
cf = CachedFile.objects.create(
expires=now() + timedelta(days=1),
@@ -100,41 +92,47 @@ class ImportView(EventPermissionRequiredMixin, TemplateView):
else:
charset = "auto"
return redirect(reverse('control:event.orders.import.process', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
'file': cf.id
}) + "?charset=" + charset)
return redirect(self.get_process_url(request, cf, charset))
def get_context_data(self, **kwargs):
return super().get_context_data(encodings=ENCODINGS)
def get_process_url(self, request, cf, charset):
raise NotImplementedError() # noqa
class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
permission = 'can_change_orders'
template_name = 'pretixcontrol/orders/import_process.html'
form_class = ProcessForm
task = import_orders
class BaseProcessView(AsyncAction, FormView):
known_errortypes = ['DataImportError']
@property
def settings_key(self):
raise NotImplementedError() # noqa
@property
def settings_holder(self):
raise NotImplementedError() # noqa
def get_form_kwargs(self):
k = super().get_form_kwargs()
k.update({
'event': self.request.event,
'initial': self.request.event.settings.order_import_settings,
'initial': self.settings_holder.settings.get(self.settings_key, as_type=dict),
'headers': self.parsed.fieldnames
})
return k
def form_valid(self, form):
self.request.event.settings.order_import_settings = form.cleaned_data
self.settings_holder.settings.set(self.settings_key, form.cleaned_data)
if self.request.GET.get("charset") in ENCODINGS:
charset = self.request.GET.get("charset")
else:
charset = None
return self.do(
self.request.event.pk, self.file.id, form.cleaned_data, self.request.LANGUAGE_CODE,
self.request.user.pk, charset
self.settings_holder.pk,
self.file.id,
form.cleaned_data,
self.request.LANGUAGE_CODE,
self.request.user.pk,
charset,
)
@cached_property
@@ -176,28 +174,21 @@ class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
return _('The import was successful.')
def get_success_url(self, value):
return reverse('control:event.orders', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
raise NotImplementedError() # noqa
def get_form_url(self):
raise NotImplementedError() # noqa
def dispatch(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
if not self.parsed or not self.parsed_list:
messages.error(request, _('We\'ve been unable to parse the uploaded file as a CSV file.'))
return redirect(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return redirect(self.get_form_url())
return super().dispatch(request, *args, **kwargs)
def get_error_url(self):
return reverse('control:event.orders.import.process', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
'file': self.file.id
})
return self.request.path
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
@@ -205,3 +196,89 @@ class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
ctx['parsed'] = self.parsed
ctx['sample_rows'] = self.parsed_list[:3]
return ctx
class OrderImportView(EventPermissionRequiredMixin, BaseImportView):
template_name = 'pretixcontrol/orders/import_start.html'
permission = 'can_change_orders'
def get_process_url(self, request, cf, charset):
return reverse('control:event.orders.import.process', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
'file': cf.id
}) + "?charset=" + charset
class OrderProcessView(EventPermissionRequiredMixin, BaseProcessView):
permission = 'can_change_orders'
template_name = 'pretixcontrol/orders/import_process.html'
form_class = OrdersProcessForm
task = import_orders
settings_key = 'order_import_settings'
@property
def settings_holder(self):
return self.request.event
def get_form_kwargs(self):
k = super().get_form_kwargs()
k.update({
'event': self.request.event,
})
return k
def get_form_url(self):
return reverse('control:event.orders.import', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
def get_success_url(self, value):
return reverse('control:event.orders', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
class VoucherImportView(EventPermissionRequiredMixin, BaseImportView):
template_name = 'pretixcontrol/vouchers/import_start.html'
permission = 'can_change_vouchers'
def get_process_url(self, request, cf, charset):
return reverse('control:event.vouchers.import.process', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
'file': cf.id
}) + "?charset=" + charset
class VoucherProcessView(EventPermissionRequiredMixin, BaseProcessView):
permission = 'can_change_vouchers'
template_name = 'pretixcontrol/vouchers/import_process.html'
form_class = VouchersProcessForm
task = import_vouchers
settings_key = 'voucher_import_settings'
@property
def settings_holder(self):
return self.request.event
def get_form_kwargs(self):
k = super().get_form_kwargs()
k.update({
'event': self.request.event,
})
return k
def get_form_url(self):
return reverse('control:event.vouchers.import', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
def get_success_url(self, value):
return reverse('control:event.vouchers', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
+36 -31
View File
@@ -129,6 +129,7 @@ from pretix.control.forms.rrule import RRuleForm
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import order_search_forms
from pretix.control.views import PaginationMixin
from pretix.helpers import OF_SELF
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.format import format_map
from pretix.helpers.safedownload import check_token
@@ -1562,20 +1563,22 @@ class OrderInvoiceCreate(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
has_inv = self.order.invoices.exists() and not (
self.order.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
and self.order.invoices.filter(is_cancellation=True).count() >= self.order.invoices.filter(is_cancellation=False).count()
)
if self.request.event.settings.get('invoice_generate') not in ('admin', 'user', 'paid', 'True') or not invoice_qualified(self.order):
messages.error(self.request, _('You cannot generate an invoice for this order.'))
elif has_inv:
messages.error(self.request, _('An invoice for this order already exists.'))
else:
inv = generate_invoice(self.order)
self.order.log_action('pretix.event.order.invoice.generated', user=self.request.user, data={
'invoice': inv.pk
})
messages.success(self.request, _('The invoice has been generated.'))
with transaction.atomic():
order = Order.objects.select_for_update(of=OF_SELF).get(pk=self.order.pk)
has_inv = order.invoices.exists() and not (
order.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
and order.invoices.filter(is_cancellation=True).count() >= order.invoices.filter(is_cancellation=False).count()
)
if self.request.event.settings.get('invoice_generate') not in ('admin', 'user', 'paid', 'True') or not invoice_qualified(order):
messages.error(self.request, _('You cannot generate an invoice for this order.'))
elif has_inv:
messages.error(self.request, _('An invoice for this order already exists.'))
else:
inv = generate_invoice(order)
order.log_action('pretix.event.order.invoice.generated', user=self.request.user, data={
'invoice': inv.pk
})
messages.success(self.request, _('The invoice has been generated.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
@@ -1657,25 +1660,27 @@ class OrderInvoiceReissue(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
try:
inv = self.order.invoices.get(pk=kwargs.get('id'))
except Invoice.DoesNotExist:
messages.error(self.request, _('Unknown invoice.'))
else:
if inv.canceled:
messages.error(self.request, _('The invoice has already been canceled.'))
elif inv.shredded:
messages.error(self.request, _('The invoice has been cleaned of personal data.'))
with transaction.atomic():
order = Order.objects.select_for_update(of=OF_SELF).get(pk=self.order.pk)
try:
inv = order.invoices.get(pk=kwargs.get('id'))
except Invoice.DoesNotExist:
messages.error(self.request, _('Unknown invoice.'))
else:
c = generate_cancellation(inv)
if self.order.status not in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
inv = generate_invoice(self.order)
if inv.canceled:
messages.error(self.request, _('The invoice has already been canceled.'))
elif inv.shredded:
messages.error(self.request, _('The invoice has been cleaned of personal data.'))
else:
inv = c
self.order.log_action('pretix.event.order.invoice.reissued', user=self.request.user, data={
'invoice': inv.pk
})
messages.success(self.request, _('The invoice has been reissued.'))
c = generate_cancellation(inv)
if order.status not in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
inv = generate_invoice(order)
else:
inv = c
order.log_action('pretix.event.order.invoice.reissued', user=self.request.user, data={
'invoice': inv.pk
})
messages.success(self.request, _('The invoice has been reissued.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs): # NOQA

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