Compare commits

..
Author SHA1 Message Date
robbi5 488746044b Don't select last added element on load 2026-09-24 18:53:17 +02:00
robbi5 81d7dfbbc4 Select the new element right after adding it in the pdf editor 2026-09-24 18:15:25 +02:00
Richard Schreiber ed68719731 Widget: fix calendar view showing old events due to non-unique key (Z#23247433) (#6585)
* Widget: fix calendar view showing old events due to cache-key (Z#23247433)

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

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

* Fix logentry

* Fix logentry again

* validate and add tests

* add meta_properties from organizer only

* fix choices validation

* filter unknown keys from choices due to django-formsets

* Apply batched suggestions from code review

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

* add safe-guard normalization to None to to_representation

* Apply batched suggestions from code review

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

* update tests to check for error-messages as well

* fix flake8

* fix permission tests

* Make ObjectListField more flexibel for re-use

* fix validation result

* Improve validation

* Change to I18nField for validation

* update MetaPropertyDictField

* fix docs for i18n strings

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

* undo test changes in events test

* fix flake8

* Apply batched suggestions from code review

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

* improve code formatting

---------

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

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

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

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

* Add reset button

* Reset throttle

* Refactoring

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

This was already correct if the header was a h1

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

This reverts commit 3d25316fe3.

* Event header: Remove date directly above other date

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

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

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

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

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

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

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

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

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

Leaving only migrations behind

* Try to fix CI

* Fix tests some more

* Rename more plugin name instances in tests and docs

---------

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

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

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

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

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

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

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

* Update pyproject.toml

---------

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

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

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

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

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

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

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

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

* Run isort

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:14:06 +02:00
dependabot[bot] 6bbd808aaa Bump postcss-selector-parser from 7.1.1 to 7.1.5 (#6513)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 7.1.1 to 7.1.5.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/v7.1.1...7.1.5)

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

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

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

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

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

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

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

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

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

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

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

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

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

* Apply suggestion from @pajowu

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

* Fix typing stuff

* Fix precision issue

---------

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.14.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:46:16 +02:00
dependabot[bot] ca77467e48 Update sentry-sdk requirement from ==2.68.* to ==2.69.* (#6556)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.68.0...2.69.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:46:07 +02:00
Raphael Michel d3cbfcd4da Invoice export: Add transmission-specific data (Z#23239732) (#6522) 2026-09-15 16:44:00 +02:00
Richard Schreiber 3648ef28d7 Fix deanonymizing emails with multiple dots (#6559) 2026-09-15 13:16:36 +02:00
Richard Schreiber 689b99c3aa PDF: fix inline merge_background on fg_pdf instead of its own PdfWriter (#6555)
* PDF: fix inline merge_background on fg_pdf instead of its own PdfWriter

* fix pdf_header

* fix flake8
2026-09-14 15:45:54 +02:00
Raphael Michel 6c40daa863 Hotfix for JS issue when submit() is called from JS 2026-09-14 15:33:25 +02:00
dependabot[bot]andRaphael Michel efdc83f72a Update pypdf requirement from ==6.5.* to ==6.11.* (#6176)
* Update pypdf requirement from ==6.5.* to ==6.11.*

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

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

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

* Try to adjust code

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-09-14 14:09:28 +02:00
dependabot[bot] 8b10534c74 Bump smol-toml from 1.6.1 to 1.7.1 (#6546)
Bumps [smol-toml](https://github.com/squirrelchat/smol-toml) from 1.6.1 to 1.7.1.
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.1...v1.7.1)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.7.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:31:12 +02:00
Raphael Michel bcacff2ee7 Voucher list: Add budget and budget used to CSV export (Z#23237857) (#6520)
* Voucher list: Add budget and budget used to CSV export (Z#23237857)

* Fix failing test
2026-09-14 10:31:07 +02:00
Kara Engelhardt 47096f7f1b sendmail: fix query performance when filtering on checkin (Z#23244045) 2026-09-14 10:24:58 +02:00
Raphael Michel c240ebcb8e Force Django update 2026-09-14 09:51:32 +02:00
Raphael Michel ce3e59e020 PDF reports: Fix headers for overlong event names (Z#23239566) (#6521) 2026-09-14 09:38:44 +02:00
Raphael Michel 4a9da40028 [A11y] Form JavaScript: Support for <button formnovalidate> (#6536) 2026-09-14 09:38:34 +02:00
Raphael Michelandpajowu eab1efd5f4 Phone number input: Fix workaround for prefixes shared between countries (fixes #6544) (#6545)
* Phone number input: Fix workaround for prefixes shared between countries (fixes #6544)

* Update src/pretix/base/forms/questions.py

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

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-09-14 09:35:32 +02:00
Richard Schreiber c7290b614e Show payment-QR-codes on mobile (#6503) 2026-09-14 08:50:45 +02:00
Raphael Michel 1aea5633eb Discounts: Fix ManyToMany fields when cloning (#6547) 2026-09-11 18:35:37 +02:00
Richard Schreiber 86cc719651 Widget: serve vite by default, origin-whitelist for vue2 (#6403) 2026-09-11 08:53:42 +02:00
Mira Weller 742a69f09f Add git-blame-ignore-revs file (see #6541) 2026-09-10 12:54:51 +02:00
Yasunobu YesNo Kawaguchi c05dee7af5 Translations: Update Japanese
Currently translated at 100.0% (6419 of 6419 strings)

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

powered by weblate
2026-09-10 12:36:49 +02:00
Albizuri e399a49c8b Translations: Update Basque
Currently translated at 48.0% (3086 of 6419 strings)

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

powered by weblate
2026-09-10 12:36:49 +02:00
Linnea Thelander b9b951e56b Translations: Update Swedish
Currently translated at 98.0% (255 of 260 strings)

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

powered by weblate
2026-09-10 12:36:49 +02:00
Linnea Thelander 88bb1f4008 Translations: Update Swedish
Currently translated at 85.8% (5509 of 6419 strings)

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

powered by weblate
2026-09-10 12:36:49 +02:00
Renne Rocha 24e7db7ddd Translations: Update Portuguese (Brazil)
Currently translated at 92.4% (5937 of 6419 strings)

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

powered by weblate
2026-09-10 12:36:49 +02:00
Hijiri Umemoto 96ed6fe1a6 Translations: Update Japanese
Currently translated at 100.0% (6419 of 6419 strings)

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

powered by weblate
2026-09-10 12:36:49 +02:00
Raphael MichelandKara Engelhardt d85e52c83e ESlint: Ignore vendored and pre-vue code (#6541)
* ESlint: Ignore vendored and pre-vue code

* Format pre-vue code with eslint where possible

---------

Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-09-10 12:35:10 +02:00
Raphael Michel d9b5fa5b16 Fix incomplete warning message 2026-09-10 12:16:10 +02:00
Kara Engelhardt fb948cc7f4 sendmail: small performance winds in checkin query 2026-09-10 11:17:32 +02:00
Kara Engelhardt c358f412fd sendmail: code cleanup 2026-09-10 11:17:32 +02:00
Kara Engelhardt b78de6386b sendmail: fix quadratic checkin query 2026-09-10 11:17:32 +02:00
Raphael MichelandRichard Schreiber 50c00b47ae Event index: Add subevent time and name to <title> (Z#23246174) (#6542)
* Event index: Add subevent time and name to <title> (Z#23246174)

* Update src/pretix/presale/templates/pretixpresale/event/index.html

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-10 10:05:55 +02:00
Ferdinand Malcher 6025c7942f Product pictures: fix CSS to always fit image to viewport (#6527) 2026-09-09 14:10:15 +02:00
dependabot[bot] 23a3412154 Update django-statici18n requirement from ==2.7.* to ==2.8.* (#6529)
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.7.0...v2.8.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 14:08:54 +02:00
Raphael MichelandRichard Schreiber 3635f6fb0c Widget: Add data-fix equivalent for attendee data (Z#23202714) (#6533)
* Widget: Add data-fix equivalent for attendee data (Z#23202714)

* Update src/pretix/presale/checkoutflow.py

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

* Update src/pretix/presale/checkoutflow.py

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

* Update src/pretix/presale/checkoutflow.py

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-09 14:06:41 +02:00
Kara Engelhardt 2aadfa2ce4 RRuleForm: allow selecting the fourth occurence of a weekday of a month (Z#23245893)
The fourth often is the same as the last, but not always. A weekday can appear five times in a month
2026-09-09 12:21:42 +02:00
Raphael Michel dd0d78242e PDF editor: Harden cached file access for background (Z#23242291) (#6446) 2026-09-09 11:13:29 +02:00
Raphael Michelandluelista 42c5de895c CachedFileField: Bind file access to session (Z#23242301) (#6447)
* CachedFileField: Bind file access to session (Z#23242301)

* Apply suggestion from @luelista

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

---------

Co-authored-by: luelista <weller@rami.io>
2026-09-09 09:52:40 +02:00
Raphael Michelandluelista e91718e73b Password reset: Prevent race condition that uses token twice (Z#23242132) (#6448)
* Password reset: Prevent race condition that uses token twice

* Apply suggestion from @luelista

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

---------

Co-authored-by: luelista <weller@rami.io>
2026-09-09 09:52:38 +02:00
Martin GrossandRaphael Michel 2c15d8b074 Event Settings: Make contact_url and imprint_url I18nURLField (Z#23238623) (#6506)
* Event Settings: Make contact_url and imprint_url I18nURLField (Z#23238623)

* isort

* Update doc/api/resources/events.rst

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-09-09 09:14:37 +02:00
Raphael MichelandLukas Bockstaller edb4069e18 Payment step: Allow to postpone payment choice on some sales channels (#6516)
* Payment step: Allow to postpone payment choice on some sales channels

* Add tests

* handle payment provider (de-)selection and partial payments (#6526)

---------

Co-authored-by: Lukas Bockstaller <bockstaller@pretix.eu>
2026-09-08 09:32:15 +02:00
174 changed files with 13630 additions and 13253 deletions
+2
View File
@@ -0,0 +1,2 @@
# Format pre-vue code with eslint where possible (2026-09-10)
d85e52c83ed3639e040372fd0052e842e4899d90
-2
View File
@@ -16,8 +16,6 @@ recursive-include src/pretix/plugins/banktransfer/templates *
recursive-include src/pretix/plugins/banktransfer/static *
recursive-include src/pretix/plugins/manualpayment/templates *
recursive-include src/pretix/plugins/manualpayment/static *
recursive-include src/pretix/plugins/paypal/templates *
recursive-include src/pretix/plugins/paypal/static *
recursive-include src/pretix/plugins/paypal2/templates *
recursive-include src/pretix/plugins/paypal2/static *
recursive-include src/pretix/plugins/src/pretixdroid/templates *
+241
View File
@@ -0,0 +1,241 @@
Event Meta Properties
=====================
Resource description
--------------------
An event meta property is used to to define meta information fields for its events.
This information can be re-used, for example, in ticket layouts.
The event meta property resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Unique ID for this property
name string Name of the property
default string Value of the default option
required boolean If ``true``, an event can only be taken live if the
property is set. In event series, it's always optional
to set a value for individual dates
protected boolean If ``true``, the value for an event can only be changed
by organizer-level administrators
filter_public boolean If ``true``, this property will be shown to filter
events in the public event list and calendar
public_label string Public name of the property
filter_allowed boolean If ``true``, this property will be shown to filter
events or reports in the backend, and it can also be
used for hidden filter parameters in the frontend
choices list of objects List of JSON objects representing all permitted values
for this property, or ``null`` for no limitation.
Each choice object has a required internal name named
``key`` and optional public name named ``label``
consisting of a dictionary of i18n string translations
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/
Returns a list of all meta properties for the organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "Color",
"default": "blue",
"required": false,
"protected": false,
"filter_public": false,
"public_label": {},
"filter_allowed": true,
"choices": [
{
"key": "blue",
"label": {
"en": "Blue"
},
}
]
}
]
}
:param organizer: The ``slug`` field of the organizer
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Returns information on one property, identified by its id.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
{
"id": 1,
"name": "Color",
"default": "blue",
"required": false,
"protected": false,
"filter_public": false,
"public_label": {},
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to retrieve
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/event_meta_properties/
Creates a new meta property
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/event_meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "ref-code",
"default": "abcde",
"required": true,
"choices": null
}
**Example response**:
.. sourcecode:: http
{
"id": 2,
"name": "reference",
"default": "abcde",
"required": true,
"protected": false,
"filter_public": false,
"public_label": null,
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:statuscode 201: no error
:statuscode 400: The meta property could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Update a meta property. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide
all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the
fields that you want to change.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/event_meta_properties/2/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"required": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 3,
"name": "reference",
"default": "abcde",
"required": false,
"protected": false,
"filter_public": false,
"public_label": null,
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to modify
:statuscode 200: no error
:statuscode 400: The property could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Delete a meta property.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
+17 -15
View File
@@ -110,7 +110,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"all_sales_channels": false,
@@ -199,7 +199,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"valid_keys": {
@@ -262,7 +262,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -299,7 +299,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -364,7 +364,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -401,7 +401,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -438,7 +438,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
]
}
@@ -475,7 +475,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
],
"all_sales_channels": true,
@@ -566,7 +566,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://pretix.eu",
"region": "DE",
…
}
@@ -579,12 +579,14 @@ organizer level.
Content-Type: application/json
{
"imprint_url":
"region":
{
"value": "https://pretix.eu",
"label": "Imprint URL",
"value": "DE",
"label": "Region",
"readonly": false,
"help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
"help_text": "Will be used to determine date and time formatting as well as default country for customer
addresses and phone numbers. For formatting, this takes less priority than the language and
is therefore mostly relevant for languages used in different regions globally (like English)."
}
},
…
@@ -620,7 +622,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://example.org/imprint/"
"region": "DE"
}
**Example response**:
@@ -632,7 +634,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://example.org/imprint/",
"region": "DE",
…
}
+1
View File
@@ -12,6 +12,7 @@ at :ref:`plugin-docs`.
organizers
events
subevents
event_meta_properties
taxrules
categories
items
+1
View File
@@ -116,6 +116,7 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1
:query string code: Only show the voucher with the given voucher code.
:query string search: Only show the voucher with the given query found in the code, tag, or comment.
:query integer max_usages: Only show vouchers with the given maximal number of usages.
:query integer redeemed: Only show vouchers with the given number of redemptions. Note that this doesn't tell you if
the voucher can still be redeemed, as this also depends on ``max_usages``. See the
+58 -1
View File
@@ -8,7 +8,64 @@ import vuePug from 'eslint-plugin-vue-pug'
const ignores = globalIgnores([
'**/node_modules',
'**/dist'
'**/dist',
// Vendored code
'src/pretix/static/leaflet',
'src/pretix/static/clipboard',
'src/pretix/static/cropper',
'src/pretix/static/lightbox',
'src/pretix/static/are-you-sure',
'src/pretix/static/vuejs',
'src/pretix/static/fontawesome',
'src/pretix/static/typeahead',
'src/pretix/static/moment',
'src/pretix/static/pdfjs',
'src/pretix/static/sortable',
'src/pretix/static/iframeresizer',
'src/pretix/static/bootstrap',
'src/pretix/static/d3',
'src/pretix/static/jsi18n',
'src/pretix/static/fabric',
'src/pretix/static/datetimepicker',
'src/pretix/static/charts',
'src/pretix/static/fileupload',
'src/pretix/static/seating',
'src/pretix/static/rest_framework',
'src/pretix/static/select2',
'src/pretix/static/schema',
'src/pretix/static/slider',
'src/pretix/static/jquery',
'src/pretix/static/colorpicker',
'src/pretix/static/rrule',
'src/pretix/static/pretixcontrol/js/jquery.qrcode.min.js',
'src/pretix/static/pretixpresale/js/widget/docready.js',
// Pre-vue JS code
'src/pretix/static/pretixbase/js/addressform.js',
'src/pretix/static/pretixbase/js/asynctask.js',
'src/pretix/static/pretixbase/js/details.js',
'src/pretix/static/pretixbase/js/gettextstub.js',
'src/pretix/static/pretixbase/js/i18nstring.js',
'src/pretix/static/pretixcontrol/js/menu.js',
'src/pretix/static/pretixcontrol/js/ui/editor.js',
'src/pretix/static/pretixcontrol/js/ui/geo.js',
'src/pretix/static/pretixcontrol/js/ui/main.js',
'src/pretix/static/pretixcontrol/js/ui/plugins.js',
'src/pretix/static/pretixcontrol/js/ui/subevent.js',
'src/pretix/static/pretixcontrol/js/ui/variations.js',
'src/pretix/static/pretixcontrol/js/ui/webauthn.js',
'src/pretix/static/pretixpresale/js/ui/cart.js',
'src/pretix/static/pretixpresale/js/ui/main.js',
'src/pretix/static/pretixpresale/js/ui/questions.js',
'src/pretix/static/pretixpresale/js/widget/floatformat.js',
'src/pretix/static/pretixpresale/js/widget/widget.js',
'src/pretix/plugins/banktransfer/static',
'src/pretix/plugins/paypal2/static',
'src/pretix/plugins/statistics/static',
'src/pretix/plugins/stripe/static',
// Plugin checkouts
'local',
// docs
'doc',
])
export default defineConfig([
+27 -13
View File
@@ -294,29 +294,43 @@
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -3380,9 +3394,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4148,9 +4162,9 @@
}
},
"node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
+9 -9
View File
@@ -33,14 +33,14 @@ dependencies = [
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=50.0.0",
"cryptography>=50.0.1",
"css-inline==0.21.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*",
"Django[argon2]==5.2.*,>=5.2.17",
"django-bootstrap3==26.2",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-countries==9.1.*",
"django-filter==26.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.7",
@@ -56,7 +56,7 @@ dependencies = [
"django-querytagger==0.0.3",
"django-redis==7.0.*",
"django-scopes==2.1.*",
"django-statici18n==2.7.*",
"django-statici18n==2.8.*",
"djangorestframework==3.17.*",
"dnspython==2.8.*",
"drf_ujson2==1.7.*",
@@ -75,7 +75,7 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.13.*",
"PyJWT==2.14.*",
"phonenumberslite==9.0.*",
"Pillow==12.3.*",
"pretix-plugin-build",
@@ -84,7 +84,7 @@ dependencies = [
"pycountry",
"pycparser==3.0",
"pycryptodome==3.23.*",
"pypdf==6.5.*",
"pypdf==6.19.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
@@ -94,7 +94,7 @@ dependencies = [
"redis==7.4.*",
"reportlab==5.0.*",
"requests==2.34.*",
"sentry-sdk==2.68.*",
"sentry-sdk==2.69.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -112,10 +112,10 @@ dev = [
"aiohttp==3.14.*",
"coverage",
"coveralls",
"fakeredis==2.37.*",
"fakeredis==2.38.*",
"flake8==7.3.*",
"freezegun",
"isort==8.0.*",
"isort==9.0.*",
"pep8-naming==0.15.*",
"potypo",
"pytest-asyncio>=1.4.0",
-1
View File
@@ -26,7 +26,6 @@ ignore =
src/tests/plugins/*
src/tests/plugins/badges/*
src/tests/plugins/banktransfer/*
src/tests/plugins/paypal/*
src/tests/plugins/paypal2/*
src/tests/plugins/pretixdroid/*
src/tests/plugins/stripe/*
+90 -3
View File
@@ -28,6 +28,7 @@ from django.db import transaction
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.translation import gettext, gettext_lazy as _
from i18nfield.rest_framework import I18nField
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -40,9 +41,10 @@ from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.auth import get_auth_backends
from pretix.base.i18n import get_language_without_region
from pretix.base.models import (
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
Customer, Device, EventMetaProperty, GiftCard, GiftCardAcceptance,
GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer,
ReusableMedium, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.permissions import (
@@ -640,3 +642,88 @@ class OrganizerSettingsSerializer(SettingsSerializer):
)
# TODO: make sure pub is always correct
return 'pub/' + fname
class MetaPropertyListField(serializers.ListField):
def __init__(self, *args, **kwargs):
kwargs["validators"] = kwargs.pop("validators", [])
def validate_keys_unique(choices):
if not choices:
return
keys = [c.get("key") for c in choices]
if len(set(keys)) < len(keys):
raise ValidationError("The key for each meta property value option must be unique.")
kwargs["validators"].append(
validate_keys_unique
)
super().__init__(*args, **kwargs)
class MetaPropertyDictField(serializers.DictField):
def __init__(self, **kwargs):
self.label_child = kwargs.pop("label_child", I18nField())
super().__init__(**kwargs)
def to_representation(self, value):
# django added unneccessary keys DELETE, ORDER through formsets, filter them here for backwards compat
d = {
"key": value["key"]
}
if "label" in value:
d["label"] = self.label_child.to_representation(value["label"])
return super().to_representation(d)
def to_internal_value(self, data):
if not isinstance(data, dict):
raise ValidationError("Meta property value options must be a dict.")
if not isinstance(data.get("key"), str):
raise ValidationError("Meta property value options must have a key of type string.")
if any(k not in {"key", "label"} for k in data.keys()):
raise ValidationError("Meta property value options may only have a key and optionally a label.")
if "label" in data:
try:
data["label"] = self.label_child.to_internal_value(data["label"])
except ValidationError as e:
raise ValidationError({"label": e.detail})
return super().to_internal_value(data)
class EventMetaPropertiesSerializer(I18nAwareModelSerializer):
choices = MetaPropertyListField(
child=MetaPropertyDictField(
label_child=I18nField()
),
allow_null=True,
)
class Meta:
model = EventMetaProperty
fields = (
'id', 'name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed',
'choices'
)
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
choices = full_data.get("choices")
default = full_data.get("default")
if choices and default:
choice_keys = [c.get("key") for c in choices]
if default not in choice_keys:
raise ValidationError("You cannot set a default value that is not a valid value.")
if not choices and "choices" in data:
# normalize empty dict to None
data["choices"] = None
return data
+1
View File
@@ -68,6 +68,7 @@ orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportVie
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions')
orga_router.register(r'event_meta_properties', organizer.EventMetaPropertiesViewSet)
team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet)
+51 -4
View File
@@ -44,15 +44,16 @@ from pretix.api.models import OAuthAccessToken
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.organizer import (
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
EventMetaPropertiesSerializer, GiftCardSerializer,
GiftCardTransactionSerializer, MembershipSerializer,
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer,
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
)
from pretix.base.models import (
Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry,
Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team,
TeamAPIToken, TeamInvite, User,
Customer, Device, Event, EventMetaProperty, GiftCard, GiftCardTransaction,
LogEntry, Membership, MembershipType, Organizer, SalesChannel, SeatingPlan,
Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
@@ -846,3 +847,49 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
data={'id': instance.pk}
)
instance.delete()
class EventMetaPropertiesViewSet(viewsets.ModelViewSet):
serializer_class = EventMetaPropertiesSerializer
queryset = EventMetaProperty.objects.none()
write_permission = 'organizer.settings.general:write'
def get_queryset(self):
return self.request.organizer.meta_properties.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.property.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer_id=self.request.organizer.pk)
serializer.instance.log_action(
'pretix.property.created',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(organizer_id=self.request.organizer.pk)
serializer.instance.log_action(
'pretix.property.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
+4
View File
@@ -40,6 +40,7 @@ with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
code = CharFilter(lookup_expr='iexact')
search = CharFilter(method='search_qs')
class Meta:
model = Voucher
@@ -54,6 +55,9 @@ with scopes_disabled():
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
def search_qs(self, qs, name, value):
return qs.filter(Q(code__icontains=value) | Q(tag__icontains=value) | Q(comment__icontains=value))
class VoucherViewSet(viewsets.ModelViewSet):
serializer_class = VoucherSerializer
+7 -3
View File
@@ -27,7 +27,7 @@ from datetime import timedelta
from functools import cached_property
from typing import List, Optional, Protocol
import sentry_sdk
from django.conf import settings
from django.db import DatabaseError, transaction
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -236,7 +236,9 @@ class OutboundSyncProvider:
# model changes saved by set_sync_error / clear_in_flight calls below
if sq.failed_attempts >= self.max_attempts:
logger.exception('Failed to sync order (max attempts exceeded)')
sentry_sdk.capture_exception(e)
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
sq.set_sync_error("exceeded", e.messages, e.full_message)
else:
logger.info(
@@ -247,7 +249,9 @@ class OutboundSyncProvider:
sq.clear_in_flight()
except Exception as e:
logger.exception('Failed to sync order (unhandled exception)')
sentry_sdk.capture_exception(e)
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
sq.set_sync_error("internal", [], str(e))
@cached_property
+27 -4
View File
@@ -54,6 +54,7 @@ from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable
from ..exporter import BaseExporter, MultiSheetListExporter
from ..invoicing.transmission import get_transmission_types
from ..services.export import ExportError
from ..services.invoices import invoice_pdf_task
from ..signals import (
@@ -197,7 +198,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
def iterate_sheet(self, form_data, sheet):
_ = gettext
if sheet == 'invoices':
yield [
headers = [
_('Invoice number'),
_('Date'),
_('Order code'),
@@ -230,8 +231,18 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Total value (without taxes)'),
_('Payment matching IDs'),
_('Payment providers'),
_('Transmission type'),
_('Transmission status'),
_('Transmission date'),
]
transmission_types = get_transmission_types()
for tt in transmission_types:
for c in tt.describe_info_columns():
headers.append(str(tt.verbose_name) + ': ' + str(c))
yield headers
p_providers = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
@@ -242,7 +253,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
'm'
).order_by()
base_qs = self.invoices_queryset(form_data)\
base_qs = self.invoices_queryset(form_data)
qs = base_qs.select_related(
'order', 'refers'
@@ -280,7 +291,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
if mid:
pmis.append(mid)
pmi = '\n'.join(pmis)
yield [
line = [
i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
@@ -315,8 +326,20 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
', '.join([
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
if p and p != 'free'
])
]),
i.transmission_type_instance.verbose_name,
i.get_transmission_status_display(),
date_format(i.transmission_date, "SHORT_DATETIME_FORMAT") if i.transmission_date else "",
]
for tt in transmission_types:
if tt.identifier == i.transmission_type:
described = dict(tt.describe_info(i.invoice_to_transmission_info, i.invoice_to_country, i.invoice_to_is_business))
for c in tt.describe_info_columns():
line.append(described.get(c, ""))
else:
for c in tt.describe_info_columns():
line.append("")
yield line
elif sheet == 'lines':
yield [
_('Invoice number'),
+17 -9
View File
@@ -350,16 +350,22 @@ class WrappedPhonePrefixSelect(Select):
return super().render(name, value or self.initial, *args, **kwargs)
def get_context(self, name, value, attrs):
if value and self.choices[1][0] != value:
matching_choices = len([1 for p, c in self.choices if p == value])
# self.choices is lazy evaluated, needs to be realized to be modifiable
choices = list(self.choices)
if value and choices[1][0] != value:
matching_choices = len([1 for p, c in choices if p == value])
if matching_choices > 1:
# Some countries share a phone prefix, for example +1 is used all over the Americas.
# This causes a UX problem: If the default value or the existing data is +12125552368,
# the widget will just show the first <option> entry with value="+1" as selected,
# which alphabetically is America Samoa, although most numbers statistically are from
# the US. As a workaround, we detect this case and add an aditional choice value with
# the US. As a workaround, we detect this case and add an additional choice value with
# just <option value="+1">+1</option> without an explicit country.
self.choices.insert(1, (value, value))
self.choices = [
choices[0],
(value, value),
*choices[1:],
]
context = super().get_context(name, value, attrs)
return context
@@ -1196,8 +1202,9 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
return field
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
validate_address,
)
d = super().clean()
@@ -1438,8 +1445,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on'
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
validate_address,
)
data = self.cleaned_data
@@ -1493,7 +1501,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
"vat_id": _("This field is required.")
})
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
if self.validate_vat_id and self.instance.vat_id_validated and not any(v in self.changed_data for v in ('is_business', 'vat_id', 'country')):
pass # Skip re-validation if it is validated
elif self.validate_vat_id and vat_id_applicable:
try:
+2 -2
View File
@@ -82,8 +82,8 @@ class UserSettingsForm(forms.ModelForm):
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
('otp_totp.totpdevice', _('Smartphone with the Authenticator application')),
('pretixbase.webauthndevice', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
@@ -107,6 +107,9 @@ class TransmissionType:
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info
def describe_info_columns(self):
return [f.label for f in self.invoice_address_form_fields.values()]
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
form_data = self.transmission_info_to_form_data(transmission_info)
data = []
@@ -0,0 +1,49 @@
# Generated by Django 5.2.17 on 2026-09-21 11:30
import django.db.models.deletion
from django.db import migrations, models
def fix_unshredded_invoices(apps, _):
Invoice = apps.get_model("pretixbase", "Invoice")
InvoiceLine = apps.get_model("pretixbase", "InvoiceLine")
ignore_fields = (
# bool/int fields are not listed and skipped automatically
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
)
for i in Invoice.objects.filter(shredded=True):
for f in Invoice._meta.fields:
if f.name in ignore_fields:
continue
val = getattr(i, f.name, None)
if val and isinstance(val, str):
setattr(i, f.name, "█")
elif val and isinstance(val, list): # jsonfield
setattr(i, f.name, [])
elif val and isinstance(val, dict): # jsonfield
setattr(i, f.name, {"_shredded": True})
i.save()
InvoiceLine.objects.filter(
attendee_name__isnull=False,
invoice__shredded=True
).update(attendee_name="█")
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.RunPython(
fix_unshredded_invoices,
migrations.RunPython.noop,
),
]
+1
View File
@@ -166,6 +166,7 @@ class Device(LoggedModel):
)
security_profile = models.CharField(
max_length=190,
verbose_name=_('Security profile'),
default='full',
null=True,
blank=False
+7 -40
View File
@@ -626,47 +626,14 @@ class Order(LockModel, LoggedModel):
self.save(update_fields=['last_modified'])
def set_expires(self, now_dt=None, subevents=None):
now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone)
from pretix.base.services.payment import compute_payment_deadline
sales_channel_suffix = "_" + self.sales_channel.identifier.replace(".", "_")
if not (mode := self.event.settings.get(f'payment_term_mode{sales_channel_suffix}')):
mode = self.event.settings.get('payment_term_mode')
sales_channel_suffix = ""
if mode == 'days':
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int))
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if self.event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
exp_by_date += timedelta(days=2)
elif exp_by_date.weekday() == 6:
exp_by_date += timedelta(days=1)
elif mode == 'minutes':
exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int))
else:
raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode))
self.expires = exp_by_date
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if self.event.has_subevents and subevents:
terms = [
term_last.datetime(se).date()
for se in subevents
]
if not terms:
return
term_last = min(terms)
else:
term_last = term_last.datetime(self.event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
if term_last < self.expires:
self.expires = term_last
self.expires = compute_payment_deadline(
event=self.event,
sales_channel=self.sales_channel,
now_dt=now_dt,
subevents=subevents,
)
@cached_property
def tax_total(self):
+40 -34
View File
@@ -801,6 +801,18 @@ def generate_compressed_addon_list(op, order, event, only_checked_in=False):
return addonlist
def get_sizebox(page: pypdf.PageObject):
mediabox = page.mediabox
cropbox = page.cropbox
return pypdf.generic.RectangleObject((
max(mediabox[0], cropbox[0]),
max(mediabox[1], cropbox[1]),
min(mediabox[2], cropbox[2]),
min(mediabox[3], cropbox[3]),
))
class Renderer:
def __init__(self, event, layout, background_file):
@@ -1153,11 +1165,10 @@ class Renderer:
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
page_size = (
self.bg_pdf.pages[0].mediabox[2] - self.bg_pdf.pages[0].mediabox[0],
self.bg_pdf.pages[0].mediabox[3] - self.bg_pdf.pages[0].mediabox[1]
)
if self.bg_pdf.pages[0].get('/Rotate') in (90, 270):
first_page = self.bg_pdf.pages[0]
sizebox = get_sizebox(first_page)
page_size = (sizebox.width, sizebox.height)
if first_page.rotation in (90, 270):
# swap dimensions due to pdf being rotated
page_size = page_size[::-1]
canvas.setPageSize(page_size)
@@ -1232,9 +1243,7 @@ class Renderer:
for i, page in enumerate(fg_pdf.pages):
bg_page = self.bg_pdf.pages[i]
_correct_page_media_box(bg_page)
page.merge_page(bg_page, over=False)
output.add_page(page)
_merge_with_correct_page_media_box(output, page, bg_page)
# pdf_header is a string like "%pdf-X.X"
if float(self.bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
@@ -1299,39 +1308,36 @@ def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
bg_pdf.write(bg_filename)
subprocess.run(pdftk_cmd, check=True, stdout=out_file)
else:
output = PdfWriter()
for i, page in enumerate(fg_pdf.pages):
bg_page = bg_pdf.pages[i]
_correct_page_media_box(bg_page)
page.merge_page(bg_page, over=False)
_merge_with_correct_page_media_box(output, page, bg_page)
# pdf_header is a string like "%pdf-X.X"
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
fg_pdf.pdf_header = bg_pdf.pdf_header
fg_pdf.write(out_file)
output.pdf_header = (
bg_pdf.pdf_header
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:])
else fg_pdf.pdf_header
)
output.write(out_file)
def _correct_page_media_box(page: pypdf.PageObject):
if page.rotation != 0:
page.transfer_rotation_to_content()
media_box = page.mediabox
def _merge_with_correct_page_media_box(output: pypdf.PdfWriter, fg_page: pypdf.PageObject, bg_page: pypdf.PageObject):
"""
Adds fg_page to output, merging bg_page behind it.
If bg_page has a non-zero mergebox/cropbox or is rotated via /Rotate, a transformation is applied to fix this."""
trsf = pypdf.Transformation()
if media_box.bottom != 0:
trsf = trsf.translate(0, -media_box.bottom)
if media_box.left != 0:
trsf = trsf.translate(-media_box.left, 0)
page.add_transformation(trsf, False)
for b in ["/MediaBox", "/CropBox", "/BleedBox", "/TrimBox", "/ArtBox"]:
if b in page:
rr = pypdf.generic.RectangleObject(page[b])
pt1 = trsf.apply_on(rr.lower_left)
pt2 = trsf.apply_on(rr.upper_right)
page[pypdf.generic.NameObject(b)] = pypdf.generic.RectangleObject((
min(pt1[0], pt2[0]),
min(pt1[1], pt2[1]),
max(pt1[0], pt2[0]),
max(pt1[1], pt2[1]),
))
if bg_page.rotation != 0:
trsf = trsf.rotate(-bg_page.rotation)
mb = get_sizebox(bg_page)
pt1 = trsf.apply_on(mb.lower_left)
pt2 = trsf.apply_on(mb.upper_right)
trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1]))
fg_page = output.add_page(fg_page)
fg_page.merge_transformed_page(bg_page, trsf, over=False, expand=False)
@deconstructible
+1
View File
@@ -1605,6 +1605,7 @@ def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
cart_session['payments_postpone'] = False
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
+18 -3
View File
@@ -961,7 +961,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False):
meta_info: dict, event: Event, sales_channel: SalesChannel, require_approval=False):
fees = []
# Pre-rounding, pre-fee total is used for fee calculation
total = sum([c.gross_price_before_rounding for c in positions])
@@ -1021,7 +1021,14 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
payments_assigned += to_pay
p['payment_amount'] = to_pay
if total != payments_assigned and not require_approval:
allow_postponed_payment = (
require_approval or
(
sales_channel.identifier in event.settings.payment_choice_postpone_allowed_channels and not payment_requests
)
)
if total != payments_assigned and not allow_postponed_payment:
raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees
@@ -1043,7 +1050,15 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
# Final calculation of fees, also performs final rounding
try:
fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
fees = _apply_rounding_and_fees(
positions,
payment_requests,
address,
meta_info,
event,
sales_channel=sales_channel,
require_approval=require_approval
)
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
+76
View File
@@ -0,0 +1,76 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from django.utils.timezone import make_aware, now
from pretix.base.models import Event, SalesChannel
from pretix.base.reldate import RelativeDateWrapper
def compute_payment_deadline(event: Event, sales_channel: SalesChannel, now_dt=None, subevents=None) -> datetime:
now_dt = now_dt or now()
tz = ZoneInfo(event.settings.timezone)
sales_channel_suffix = "_" + sales_channel.identifier.replace(".", "_")
if not (mode := event.settings.get(f'payment_term_mode{sales_channel_suffix}')):
mode = event.settings.get('payment_term_mode')
sales_channel_suffix = ""
if mode == 'days':
exp_by_date = now_dt.astimezone(tz) + timedelta(
days=event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int))
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
exp_by_date += timedelta(days=2)
elif exp_by_date.weekday() == 6:
exp_by_date += timedelta(days=1)
elif mode == 'minutes':
exp_by_date = now_dt.astimezone(tz) + timedelta(
minutes=event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int))
else:
raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode))
expires = exp_by_date
term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if event.has_subevents and subevents:
terms = [
term_last.datetime(se).date()
for se in subevents
]
if not terms:
return expires
term_last = min(terms)
else:
term_last = term_last.datetime(event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
if term_last < expires:
return term_last
return expires
File diff suppressed because one or more lines are too long
+22 -10
View File
@@ -50,8 +50,8 @@ from pretix.api.serializers.order import (
from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
CachedCombinedTicket, CachedTicket, Event, Invoice, InvoiceAddress,
OrderPayment, OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders
@@ -598,18 +598,30 @@ class InvoiceShredder(BaseDataShredder):
def shred_data(self, progress_callback=None):
qs_i = self.event.invoices.filter(shredded=False)
total = qs_i.count()
ignore_fields = (
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
)
for i in _progress_helper(qs_i, progress_callback, 0, total):
if i.file:
i.file.delete()
i.shredded = True
i.introductory_text = "█"
i.additional_text = "█"
i.invoice_to = "█"
i.payment_provider_text = "█"
i.transmission_info = {"_shredded": True}
i.save()
i.lines.update(description="█")
i.shredded = True
for f in Invoice._meta.fields:
if f.name in ignore_fields:
continue
val = getattr(i, f.name, None)
if val and isinstance(val, str):
setattr(i, f.name, "█")
elif val and isinstance(val, list): # jsonfield
setattr(i, f.name, [])
elif val and isinstance(val, dict): # jsonfield
setattr(i, f.name, {"_shredded": True})
i.save()
i.lines.update(description="█", attendee_name="█")
class CachedTicketShredder(BaseDataShredder):
+52 -23
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional
from babel import Locale, UnknownLocaleError
from babel.numbers import format_currency
@@ -35,32 +36,32 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Decimal, arg='', hide_currency=False):
if isinstance(value, (float, int)):
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
if isinstance(value, (float, int, str)):
if value == '':
return value
value = Decimal(value)
if value is None:
value = Decimal('0.00')
if not isinstance(value, Decimal):
if value == '':
return value
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
if not arg:
raise ValueError("No currency passed.")
arg = arg.upper()
places = settings.CURRENCY_PLACES.get(arg, 2)
rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
if places < 2 and rounded != value:
# We display decimal places even if we shouldn't for this currency if rounding
# would make the numbers incorrect. If this branch executes, it's likely a bug in
# pretix, but we won't show wrong numbers!
if hide_currency:
return floatformat(value, "2g")
else:
return '{} {}'.format(arg, floatformat(value, "2g"))
if value.normalize().as_tuple().exponent < -9:
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
# land to this filter (even though it should not happen).
value = value.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
currency_places = settings.CURRENCY_PLACES.get(arg, 2)
required_places = -value.normalize().as_tuple().exponent
render_places = max(currency_places, required_places)
if hide_currency:
return floatformat(value, f"{places}g")
return floatformat(value, f"{render_places}g")
try:
locale = Locale(get_babel_locale())
@@ -68,14 +69,29 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
locale = "en"
try:
return format_currency(value, arg, locale=locale)
return format_currency(
value,
arg,
locale=locale,
# We only allow Babel to restrict the digits to the digits defined by the currency if this does not remove any
# precision in case we have sub-currency precision (which we shouldn't have in most places, but it's still
# better than showing wrong data). Note: Weird precision effects can occur after in-database arithmetic
# on SQLite, since SQLite does not have fixed-decimal computation.
currency_digits=currency_places >= required_places,
decimal_quantization=currency_places >= required_places,
)
except:
return '{} {}'.format(arg, floatformat(value, f"{places}g"))
return '{} {}'.format(arg, floatformat(value, f"{render_places}g"))
@register.filter("money_without_currency")
def money_filter_without_currency(value: Optional[Decimal | float | int | str], arg=''):
return money_filter(value, arg, hide_currency=True)
@register.filter("money_numberfield")
def money_numberfield_filter(value: Decimal, arg=''):
if isinstance(value, (float, int)):
def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg=''):
if isinstance(value, (float, int, str)):
value = Decimal(value)
if not isinstance(value, Decimal):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
@@ -87,15 +103,28 @@ def money_numberfield_filter(value: Decimal, arg=''):
@register.filter(is_safe=True)
def tax_rate_format(number):
def tax_rate_format(number: Optional[Decimal | float | int | str]):
"""
Display a Decimal to its significant decimal places, used for tax rates.
"""
assert isinstance(number, Decimal)
if isinstance(number, (float, int, str)):
if number == '':
return number
number = Decimal(number)
if number is None:
number = Decimal('0.00')
if not isinstance(number, Decimal):
raise TypeError("Invalid data type passed to tax rate format filter: %r" % type(number))
if number.normalize().as_tuple().exponent < -9:
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
# land to this filter (even though it should not happen).
number = number.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
return mark_safe(
formats.number_format(
number.normalize(),
-number.as_tuple().exponent,
number,
-number.normalize().as_tuple().exponent,
use_l10n=True,
force_grouping=False,
)
+7 -2
View File
@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.conf import settings
from django.http import (
HttpResponseForbidden, HttpResponseNotFound, HttpResponseServerError,
)
@@ -27,7 +28,6 @@ from django.template import TemplateDoesNotExist, loader
from django.template.loader import get_template
from django.utils.functional import Promise
from django.utils.translation import gettext as _
from sentry_sdk import last_event_id
from pretix.base.i18n import language
from pretix.base.middleware import get_language_from_request
@@ -106,9 +106,14 @@ def server_error(request):
template = loader.get_template('500.html')
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
if settings.SENTRY_ENABLED:
from sentry_sdk import last_event_id
sentry_id = last_event_id()
else:
sentry_id = None
r = HttpResponseServerError(template.render({
'request': request,
'sentry_event_id': last_event_id(),
'sentry_event_id': sentry_id,
}))
r.xframe_options_exempt = True
return r
+12 -1
View File
@@ -172,7 +172,9 @@ class CachedFileInput(forms.ClearableFileInput):
from ...base.models import CachedFile
v = super().value_from_datadict(data, files, name)
if v is None and data.get(name + '-cachedfile'): # An explicit "[x] clear" would be False, not None
return CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
v = CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
if not v.allowed_for_session(self.request):
v = None
return v
def get_context(self, name, value, attrs):
@@ -244,6 +246,11 @@ class ExtFileField(ExtValidationMixin, SizeFileField):
class CachedFileField(ExtFileField):
widget = CachedFileInput
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super().__init__(*args, **kwargs)
self.widget.request = self.request
def to_python(self, data):
from ...base.models import CachedFile
@@ -271,6 +278,8 @@ class CachedFileField(ExtFileField):
filename=data.name,
type=data.content_type,
)
if self.request:
cf.bind_to_session(self.request) # no salt because we want direct web access
cf.file.save(data.name, data.file)
cf.save()
data._uploaded_to = cf
@@ -294,6 +303,8 @@ class CachedFileField(ExtFileField):
filename=data.name,
type=data.content_type,
)
if self.request:
cf.bind_to_session(self.request) # no salt because we want direct web access
cf.file.save(data.name, data.file)
cf.save()
data._uploaded_to = cf
+19 -7
View File
@@ -400,10 +400,10 @@ class EventMetaValueForm(forms.ModelForm):
if self.disabled:
self.fields['value'].widget.attrs['readonly'] = 'readonly'
def clean_slug(self):
def clean_value(self):
if self.disabled:
return self.instance.value if self.instance else None
return self.cleaned_data['slug']
return self.cleaned_data['value']
class Meta:
model = EventMetaValue
@@ -855,14 +855,21 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
'payment_term_accept_late',
'payment_pending_hidden',
'payment_explanation',
'payment_choice_postpone_allowed_channels',
'tax_rule_payment',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
channels = list(self.obj.organizer.sales_channels.all())
self.fields['payment_choice_postpone_allowed_channels'].choices = [
(c.identifier, c.label) for c in channels
if c.type_instance.payment_restrictions_supported
]
self.term_channel_fields = {}
for c in self.obj.organizer.sales_channels.all():
for c in channels:
if c.type_instance.payment_restrictions_supported and c.identifier != "web":
# At the moment, it seems sufficient to allow this for the same channel types as other payment settings
# We can always introduce more flags later if needed
@@ -1628,10 +1635,15 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_') and k not in self.plain_rendering)
for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
if k.endswith('_attendee'):
if not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
elif 'subject' in k and k.replace("subject", "send") in self.fields:
v.widget.attrs["data-display-dependency"] = f'#id_{k.replace("subject", "send")}'
elif 'text' in k and k.replace("text", "send") in self.fields:
v.widget.attrs["data-display-dependency"] = f'#id_{k.replace("text", "send")}'
class TicketSettingsForm(SettingsForm):
+3 -3
View File
@@ -105,12 +105,12 @@ class GlobalSettingsForm(SettingsForm):
domain=settings.SITE_URL
)
)),
('widget_vite_origins', forms.CharField(
('widget_vue2_origins', forms.CharField(
widget=forms.Textarea(attrs={'rows': '3'}),
required=False,
# Not translated on purpose, this is a temporary feature and contains too many special case words
label="Vite widget origins",
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the new vite-based widget.",
label="Vue2 widget origins",
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the old vue2-based widget.",
))
])
responses = register_global_settings.send(self)
+2
View File
@@ -87,6 +87,7 @@ class RRuleForm(forms.Form):
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('4', pgettext_lazy('rrule', 'fourth')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
@@ -134,6 +135,7 @@ class RRuleForm(forms.Form):
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('4', pgettext_lazy('rrule', 'fourth')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
+2 -2
View File
@@ -435,10 +435,10 @@ class SubEventMetaValueForm(forms.ModelForm):
if self.disabled:
self.fields['value'].widget.attrs['readonly'] = 'readonly'
def clean_slug(self):
def clean_value(self):
if self.disabled:
return self.instance.value if self.instance else None
return self.cleaned_data['slug']
return self.cleaned_data['value']
class Meta:
model = SubEventMetaValue
+5
View File
@@ -717,6 +717,10 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'),
'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'),
'pretix.property.created': _('An organizer meta property has been created.'),
'pretix.property.deleted': _('An organizer meta property has been deleted.'),
'pretix.property.changed': _('An organizer meta property has been changed.'),
'pretix.property.reordered': _('An organizer meta property has been reordered.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
@@ -774,6 +778,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'),
'pretix.user.settings.2fa.resetdrift': _('Drift and throttle values for two-factor devices have been reset.'),
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
+2 -2
View File
@@ -85,8 +85,8 @@ class PermissionMiddleware:
"user.settings.2fa.enable",
"user.settings.2fa.disable",
"user.settings.2fa.regenemergency",
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.webauthn",
"user.settings.2fa.confirm.otp_totp.totpdevice",
"user.settings.2fa.confirm.pretixbase.webauthndevice",
"user.settings.2fa.delete",
"user.settings.2fa.leaveteams",
"auth.logout",
@@ -45,8 +45,8 @@
</p>
<div class="form-group buttons">
<input type="submit" class="btn btn-large btn-default" value="Cancel"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="Authorize"/>
<input type="submit" class="btn btn-large btn-default" value="{% trans "Cancel" %}"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="{% trans "Authorize" %}"/>
</div>
</form>
{% else %}
@@ -109,6 +109,7 @@
{% bootstrap_form_errors form layout="control" %}
{% bootstrap_field form.tax_rule_payment layout="control" %}
{% bootstrap_field form.payment_explanation layout="control" %}
{% bootstrap_field form.payment_choice_postpone_allowed_channels layout="control" %}
</fieldset>
</div>
{% if "event.settings.payment:write" in request.eventpermset %}
@@ -15,7 +15,7 @@
<div>
<div class="big-radio radio">
<label>
<input type="radio" required value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}>
<input type="radio" required value="otp_totp.totpdevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "otp_totp.totpdevice" %}checked{% endif %}>
<strong>{% trans "Smartphone with Authenticator app" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
@@ -26,7 +26,7 @@
</div>
<div class="big-radio radio">
<label>
<input type="radio" required value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}>
<input type="radio" required value="pretixbase.webauthndevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "pretixbase.webauthndevice" %}checked{% endif %}>
<strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
@@ -116,14 +116,14 @@
{% for d in devices %}
<li class="list-group-item">
<a class="btn btn-danger btn-xs pull-right flip"
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
href="{% url "control:user.settings.2fa.delete" devicetype=d.model_label device=d.pk %}">
Delete
</a>
{% if d.devicetype == "totp" %}
{% if d.model_label == "otp_totp.totpdevice" %}
<span class="fa fa-mobile"></span>
{% elif d.devicetype == "webauthn" %}
{% elif d.model_label == "pretixbase.webauthndevice" %}
<span class="fa fa-usb"></span>
{% elif d.devicetype == "u2f" %}
{% elif d.model_label == "pretixbase.u2fdevice" %}
<span class="fa fa-usb"></span>
{% endif %}
{{ d.name }}
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "User" %}{% endblock %}
{% block content %}
<h1>{% trans "User" %} {{ user.email }}</h1>
@@ -59,8 +60,83 @@
{% bootstrap_field form.is_verified layout='control' %}
{% endif %}
{% bootstrap_field form.last_login layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
{% bootstrap_field form.needs_password_change layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="panel panel-default">
<div class="panel-heading">
<button class="btn btn-default btn-xs pull-right" type="submit" form="resetdriftthrottle">
{% trans "Reset drift and throttle" %}
</button>
<h3 class="panel-title">
{% trans "Available two-factor authentication methods" %}
</h3>
</div>
<table class="panel-body table table-hover">
{% for d in devices %}
<tr>
<td>
{% if d.model_label == 'otp_totp.totpdevice' %}
TOTP
{% elif d.model_label == 'pretixbase.u2fdevice' %}
U2F
{% elif d.model_label == 'pretixbase.webauthndevice' %}
WebAuthn
{% elif d.model_label == 'otp_static.staticdevice' %}
{% trans "Emergency tokens" %}
{% endif %}
{% if d.confirmed %}
{% icon "check" %}
{% else %}
{% icon "warning" %}
{% endif %}
</td>
<td>
{{ d.name }}
</td>
<td>
{% if d.throttling_failure_timestamp %}
{% blocktrans trimmed with date=d.throttling_failure_timestamp|date:"SHORT_DATETIME_FORMAT" count cnt=d.throttling_failure_count %}
1 failed attempt since {{ date }}
{% plural %}
{{ cnt }} failed attempts since {{ date }}
{% endblocktrans %}
<br>
{% endif %}
{% if d.throttling_enabled and not d.verify_is_allowed.0 %}
<strong>
{% blocktrans trimmed with date=d.verify_is_allowed.1.locked_until|date:"SHORT_DATETIME_FORMAT" %}
Currently locked until {{ date }}
{% endblocktrans %}
</strong>
<br>
{% endif %}
{% if d.model_label == 'otp_totp.totpdevice' %}
<small>
<code>step = {{ d.step }},
t0 = {{ d.t0 }},
digits = {{ d.digits }},
tolerance = {{ d.tolerance }},
drift = {{ d.drift }},
last_t = {{ d.last_t }}</code>
</small>
{% elif d.model_label == 'pretixbase.u2fdevice' %}
<small>
<code>sign_count = {{ d.sign_count }}</code>
</small>
{% elif d.model_label == 'otp_static.staticdevice' %}
<small>
<code>token_count = {{ d.token_set.count }}</code>
</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Team memberships" %}</legend>
@@ -102,4 +178,8 @@
</div>
</div>
</div>
<form action="{% url "control:users.resetdriftthrottle" id=user.pk %}" id="resetdriftthrottle" method="post">
{% csrf_token %}
</form>
{% endblock %}
+3 -2
View File
@@ -78,6 +78,7 @@ urlpatterns = [
re_path(r'^users/(?P<id>\d+)/impersonate$', users.UserImpersonateView.as_view(), name='users.impersonate'),
re_path(r'^users/(?P<id>\d+)/anonymize$', users.UserAnonymizeView.as_view(), name='users.anonymize'),
re_path(r'^users/(?P<id>\d+)/emergencytoken$', users.UserEmergencyTokenView.as_view(), name='users.emergencytoken'),
re_path(r'^users/(?P<id>\d+)/resetdriftthrottle$', users.Reset2FADriftThrottleView.as_view(), name='users.resetdriftthrottle'),
re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
re_path(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
re_path(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
@@ -106,9 +107,9 @@ urlpatterns = [
re_path(r'^settings/2fa/regenemergency', user.User2FARegenerateEmergencyView.as_view(),
name='user.settings.2fa.regenemergency'),
re_path(r'^settings/2fa/totp/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmTOTPView.as_view(),
name='user.settings.2fa.confirm.totp'),
name='user.settings.2fa.confirm.otp_totp.totpdevice'),
re_path(r'^settings/2fa/webauthn/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmWebAuthnView.as_view(),
name='user.settings.2fa.confirm.webauthn'),
name='user.settings.2fa.confirm.pretixbase.webauthndevice'),
re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(),
name='user.settings.2fa.delete'),
re_path(r'^settings/email/confirm$', user.UserEmailConfirmView.as_view(), name='user.settings.email.confirm'),
+53 -15
View File
@@ -35,6 +35,7 @@
import base64
import json
import logging
import math
import time
from urllib.parse import quote, urljoin, urlparse
@@ -50,11 +51,12 @@ from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from django_otp import match_token
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from webauthn.helpers import generate_challenge
@@ -64,6 +66,7 @@ from pretix.base.forms.auth import (
)
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.helpers import OF_SELF
from pretix.helpers.http import get_client_ip, redirect_to_url
from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import handle_login_source, session_login
@@ -395,15 +398,17 @@ class Recover(TemplateView):
def post(self, request, *args, **kwargs):
if self.form.is_valid():
try:
user = User.objects.get(id=self.request.GET.get('id'), auth_backend='native')
except User.DoesNotExist:
return self.invalid('unknownuser')
if not default_token_generator.check_token(user, self.request.GET.get('token')):
return self.invalid('invalid')
user.set_password(self.form.cleaned_data['password'])
user.needs_password_change = False
user.save()
with transaction.atomic():
# Check token in transaction to prevent race condition
try:
user = User.objects.select_for_update(of=OF_SELF).get(id=self.request.GET.get('id'), auth_backend='native')
except User.DoesNotExist:
return self.invalid('unknownuser')
if not default_token_generator.check_token(user, self.request.GET.get('token')):
return self.invalid('invalid')
user.set_password(self.form.cleaned_data['password'])
user.needs_password_change = False
user.save()
messages.success(request, _('You can now login using your new password.'))
user.log_action('pretix.control.auth.user.forgot_password.recovered')
@@ -460,6 +465,7 @@ class Login2FAView(TemplateView):
token = request.POST.get('token', '').strip().replace(' ', '')
valid = False
retry_after = None
if 'webauthn_challenge' in self.request.session and token.startswith('{'):
challenge = self.request.session['webauthn_challenge']
@@ -515,12 +521,28 @@ class Login2FAView(TemplateView):
valid = True
break
else:
valid = match_token(self.user, token)
if isinstance(valid, StaticDevice):
with transaction.atomic():
for device in devices_for_user(self.user, for_verify=True):
if isinstance(device, StaticDevice) and len(token) < 12:
# If we enter a wrong TOTP token (which is 6 characters), do not even try if it is a valid
# emergency token, which will only "lock up" the StaticDevice due to the throttling plugin
# and just locks people out without security gain.
continue
if device.verify_token(token):
valid = True
break
elif hasattr(device, 'verify_is_allowed'):
verify_allowed, reason_dict = device.verify_is_allowed()
if not verify_allowed:
if not retry_after or reason_dict['locked_until'] > retry_after:
retry_after = reason_dict['locked_until']
else:
device = None
if isinstance(device, StaticDevice):
self.user.send_security_notice([
_("A recovery code for two-factor authentification was used to log in.")
])
if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
pretix_successful_logins.inc(1)
@@ -533,7 +555,23 @@ class Login2FAView(TemplateView):
return redirect('control:index')
else:
pretix_failed_logins.inc(1, reason="2fa")
messages.error(request, _('Invalid code, please try again.'))
msg = _('Invalid code, please try again.')
if retry_after:
seconds = (retry_after - now()).total_seconds()
minutes = seconds / 60
if minutes >= 1:
msg = ngettext(
'Invalid code. Please try again after waiting {value} minute.',
'Invalid code. Please try again after waiting {value} minutes.',
minutes,
).format(value=math.ceil(minutes))
elif seconds >= 1:
msg = ngettext(
'Invalid code. Please try again after waiting {value} second.',
'Invalid code. Please try again after waiting {value} seconds.',
seconds,
).format(value=math.ceil(seconds))
messages.error(request, msg)
return redirect('control:auth.login.2fa')
def get_context_data(self, **kwargs):
+3
View File
@@ -163,6 +163,9 @@ class DiscountCreate(EventPermissionRequiredMixin, CreateView):
i = modelcopy(self.copy_from)
i.pk = None
kwargs['instance'] = i
kwargs["initial"]["limit_sales_channels"] = self.copy_from.limit_sales_channels.all()
kwargs["initial"]["condition_limit_products"] = self.copy_from.condition_limit_products.all()
kwargs["initial"]["benefit_limit_products"] = self.copy_from.benefit_limit_products.all()
else:
kwargs['instance'] = Discount(event=self.request.event)
+20 -4
View File
@@ -1177,6 +1177,8 @@ class OrderRefundView(OrderView):
manual_value = formats.sanitize_separators(manual_value)
try:
manual_value = Decimal(manual_value)
if manual_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1206,6 +1208,8 @@ class OrderRefundView(OrderView):
giftcard_value = formats.sanitize_separators(giftcard_value)
try:
giftcard_value = Decimal(giftcard_value)
if giftcard_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1255,6 +1259,8 @@ class OrderRefundView(OrderView):
offsetting_value = formats.sanitize_separators(offsetting_value)
try:
offsetting_value = Decimal(offsetting_value)
if offsetting_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1271,6 +1277,9 @@ class OrderRefundView(OrderView):
if offset_order.event.currency != self.request.event.currency:
messages.error(self.request, _('You entered an order in an event with a different currency.'))
is_valid = False
if not self.request.user.has_event_permission(self.request.organizer, offset_order.event, 'event.orders:write', request=self.request):
messages.error(self.request, _('You entered an order in an event that you do not have access to.'))
is_valid = False
refunds.append(OrderRefund(
order=order,
payment=None,
@@ -1286,10 +1295,13 @@ class OrderRefundView(OrderView):
))
for identifier, prov in self.request.event.get_payment_providers().items():
# prof = process form, not a typo for prov(ider)
prof_value = self.request.POST.get(f'newrefund-{identifier}', '0') or '0'
prof_value = formats.sanitize_separators(prof_value)
try:
prof_value = Decimal(prof_value)
if prof_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1313,6 +1325,8 @@ class OrderRefundView(OrderView):
value = formats.sanitize_separators(value)
try:
value = Decimal(value)
if value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1342,7 +1356,12 @@ class OrderRefundView(OrderView):
))
any_success = False
if refund_selected == full_refund and is_valid:
if refund_selected != full_refund:
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
'amount.'))
is_valid = False
if is_valid:
for r in refunds:
r.save()
order.log_action('pretix.event.order.refund.created', {
@@ -1414,9 +1433,6 @@ class OrderRefundView(OrderView):
)
}))
return redirect(self.get_order_url())
else:
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
'amount.'))
def post(self, *args, **kwargs):
if self.start_form.is_valid():
+3 -3
View File
@@ -778,9 +778,9 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["events"] = self.request.user.get_events_with_permission(
"event.settings.general:write", request=self.request
).filter(organizer=self.request.organizer)
# Assumption: Who has access to modify organizer settings may see all events and disable/enable plugins
# for them. Otherwise, inconsistent situations occur.
kwargs["events"] = self.request.organizer.events.all()
kwargs["initial"] = {
"events": self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)')
}
+5
View File
@@ -27,6 +27,7 @@ from decimal import Decimal
from io import BytesIO
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -193,6 +194,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
c.expires = now() + timedelta(days=7)
c.date = now()
c.filename = 'background_preview.pdf'
c.bind_to_session(request, "ticketoutput-pdf-background")
c.type = 'application/pdf'
c.save()
c.file.save('empty.pdf', ContentFile(buffer.read()))
@@ -218,6 +220,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
c.expires = now() + timedelta(days=7)
c.date = now()
c.filename = 'background_preview.pdf'
c.bind_to_session(request, "ticketoutput-pdf-background")
c.type = 'application/pdf'
c.file = fileobj
c.save()
@@ -303,5 +306,7 @@ class FontsCSSView(TemplateView):
class PdfView(TemplateView):
def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
if not cf.allowed_for_session(request, "ticketoutput-pdf-background"):
raise PermissionDenied()
resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf')
return resp
+5
View File
@@ -1276,6 +1276,11 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie
self._default_meta = self.request.event.meta_data
for p in self.request.organizer.meta_properties.all():
if p.protected and not self.request.user.has_organizer_permission(
self.request.organizer, 'organizer.settings.general:write', request=self.request
):
continue
inst = SubEventMetaValue(property=p)
if len(matches[p.id]) == 1 and matches[p.id][0]['c'] == total:
inst.value = matches[p.id][0]['value']
+14 -24
View File
@@ -59,6 +59,7 @@ from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_scopes import scopes_disabled
@@ -85,7 +86,6 @@ from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import session_reauth
from pretix.helpers.u2f import websafe_encode
REAL_DEVICE_TYPES = (TOTPDevice, WebAuthnDevice, U2FDevice)
logger = logging.getLogger(__name__)
@@ -313,17 +313,7 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
except StaticDevice.DoesNotExist:
ctx['static_tokens_device'] = None
ctx['devices'] = []
for dt in REAL_DEVICE_TYPES:
objs = list(dt.objects.filter(user=self.request.user, confirmed=True))
for obj in objs:
if dt == TOTPDevice:
obj.devicetype = 'totp'
elif dt == U2FDevice:
obj.devicetype = 'u2f'
elif dt == WebAuthnDevice:
obj.devicetype = 'webauthn'
ctx['devices'] += objs
ctx['devices'] = [d for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)]
ctx['obligatory'] = None
if settings.PRETIX_OBLIGATORY_2FA is True:
@@ -342,9 +332,9 @@ class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView):
template_name = 'pretixcontrol/user/2fa_add.html'
def form_valid(self, form):
if form.cleaned_data['devicetype'] == 'totp':
if form.cleaned_data['devicetype'] == 'otp_totp.totpdevice':
dev = TOTPDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name'])
elif form.cleaned_data['devicetype'] == 'webauthn':
elif form.cleaned_data['devicetype'] == 'pretixbase.webauthndevice':
if not self.request.is_secure():
messages.error(self.request,
_('Security devices are only available if pretix is served via HTTPS.'))
@@ -364,11 +354,11 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
@cached_property
def device(self):
if self.kwargs['devicetype'] == 'totp':
if self.kwargs['devicetype'] == 'otp_totp.totpdevice':
return get_object_or_404(TOTPDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'webauthn':
elif self.kwargs['devicetype'] == 'pretixbase.webauthndevice':
return get_object_or_404(WebAuthnDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'u2f':
elif self.kwargs['devicetype'] == 'pretixbase.u2fdevice':
return get_object_or_404(U2FDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
def get_context_data(self, **kwargs):
@@ -386,7 +376,7 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
msgs = [
_('A two-factor authentication device has been removed from your account.')
]
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
self.request.user.require_2fa = False
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.disabled', user=self.request.user)
@@ -461,7 +451,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
).first()
if credential_id_exists:
messages.error(request, _('This security device is already registered.'))
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.pretixbase.webauthndevice', kwargs={
'device': self.device.pk
}))
@@ -475,7 +465,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
self.device.save()
self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={
'id': self.device.pk,
'devicetype': 'u2f',
'devicetype': 'pretixbase.webauthndevice',
'name': self.device.name,
})
notices = [
@@ -503,7 +493,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
except Exception:
messages.error(request, _('The registration could not be completed. Please try again.'))
logger.exception('WebAuthn registration failed')
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.pretixbase.webauthndevice', kwargs={
'device': self.device.pk
}))
@@ -537,7 +527,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={
'id': self.device.pk,
'name': self.device.name,
'devicetype': 'totp'
'devicetype': 'otp_totp.totpdevice'
})
notices = [
_('A new two-factor authentication device has been added to your account.')
@@ -563,7 +553,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
else:
messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
'that the date and time of your phone are configured correctly.'))
return redirect(reverse('control:user.settings.2fa.confirm.totp', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.otp_totp.totpdevice', kwargs={
'device': self.device.pk
}))
@@ -594,7 +584,7 @@ class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html'
def dispatch(self, request, *args, **kwargs):
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
messages.error(request, _('Please configure at least one device before enabling two-factor '
'authentication.'))
return redirect(reverse('control:user.settings.2fa'))
+23
View File
@@ -40,6 +40,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import ListView, TemplateView
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from hijack import signals
@@ -107,6 +108,9 @@ class UserEditView(AdministratorPermissionRequiredMixin, RecentAuthenticationReq
ctx['backend'] = (
b[self.object.auth_backend].verbose_name if self.object.auth_backend in b else self.object.auth_backend
)
ctx['devices'] = devices_for_user(self.object)
return ctx
def get_success_url(self):
@@ -183,6 +187,25 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
return reverse('control:users.edit', kwargs=self.kwargs)
class Reset2FADriftThrottleView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
def get(self, request, *args, **kwargs):
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
self.object.totpdevice_set.update(drift=0, throttling_failure_timestamp=None, throttling_failure_count=0)
self.object.staticdevice_set.update(throttling_failure_timestamp=None, throttling_failure_count=0)
self.object.log_action('pretix.user.settings.2fa.resetdrift', user=self.request.user)
messages.success(request, _(
'The drift values for TOTP devices have been reset.'
))
return redirect(self.get_success_url())
def get_success_url(self):
return reverse('control:users.edit', kwargs=self.kwargs)
class UserAnonymizeView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView):
template_name = "pretixcontrol/users/anonymize.html"
+4 -2
View File
@@ -144,7 +144,7 @@ class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMix
headers = [
_('Voucher code'), _('Valid until'), _('Product'), _('Reserve quota'), _('Bypass quota'),
_('Price effect'), _('Value'), _('Tag'), _('Redeemed'), _('Maximum usages'), _('Seat'),
_('Comment')
_('Comment'), _('Budget'), _('Budget used')
]
writer.writerow(headers)
@@ -170,7 +170,9 @@ class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMix
str(v.redeemed),
str(v.max_usages),
str(v.seat) if v.seat else "",
str(v.comment) if v.comment else ""
str(v.comment) if v.comment else "",
str(v.budget) if v.budget is not None else "",
str(v.budget_used) if v.budget is not None else "",
]
writer.writerow(row)
+1 -1
View File
@@ -29,7 +29,7 @@ from django.urls import reverse
def build_absolute_uri(urlname, args=None, kwargs=None):
warnings.warn(
'Usage of build_absolute_uri is confusing since there are many functions with that name. '
'Replace this usage with ',
'Replace this usage with mainreverse_absolute.',
DeprecationWarning
)
return mainreverse_absolute(urlname, args, kwargs)
+30 -23
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-06-25 14:00+0000\n"
"Last-Translator: Kim Lozano <joaquim.lozano@upc.edu>\n"
"PO-Revision-Date: 2026-09-20 14:00+0000\n"
"Last-Translator: Rita Gimenez <barcelonamusictech@gmail.com>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix/"
"ca/>\n"
"Language: ca\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.6.1\n"
"X-Generator: Weblate 2026.9.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -37,11 +37,11 @@ msgstr "Àrab"
#: pretix/_base_settings.py
msgid "Basque"
msgstr ""
msgstr "Basc"
#: pretix/_base_settings.py
msgid "Catalan"
msgstr ""
msgstr "Català"
#: pretix/_base_settings.py
msgid "Chinese (simplified)"
@@ -49,11 +49,11 @@ msgstr "Xinès (simplificat)"
#: pretix/_base_settings.py
msgid "Chinese (traditional)"
msgstr ""
msgstr "Xinès (tradicional)"
#: pretix/_base_settings.py
msgid "Czech"
msgstr ""
msgstr "Txec"
#: pretix/_base_settings.py
msgid "Croatian"
@@ -81,7 +81,7 @@ msgstr "Finlandès"
#: pretix/_base_settings.py
msgid "Galician"
msgstr ""
msgstr "Gallec"
#: pretix/_base_settings.py
msgid "Greek"
@@ -89,15 +89,15 @@ msgstr "Grec"
#: pretix/_base_settings.py
msgid "Hebrew"
msgstr ""
msgstr "Hebreu"
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr ""
msgstr "Hongarès"
#: pretix/_base_settings.py
msgid "Indonesian"
msgstr ""
msgstr "Indonesi"
#: pretix/_base_settings.py
msgid "Italian"
@@ -105,7 +105,7 @@ msgstr "Italià"
#: pretix/_base_settings.py
msgid "Japanese"
msgstr ""
msgstr "Japonès"
#: pretix/_base_settings.py
msgid "Latvian"
@@ -113,7 +113,7 @@ msgstr "Letó"
#: pretix/_base_settings.py
msgid "Norwegian Bokmål"
msgstr ""
msgstr "Noruec"
#: pretix/_base_settings.py
msgid "Polish"
@@ -129,7 +129,7 @@ msgstr "Portuguès (Brasil)"
#: pretix/_base_settings.py
msgid "Romanian"
msgstr ""
msgstr "Romanès"
#: pretix/_base_settings.py
msgid "Russian"
@@ -137,11 +137,11 @@ msgstr "Rus"
#: pretix/_base_settings.py
msgid "Slovak"
msgstr ""
msgstr "Eslovac"
#: pretix/_base_settings.py
msgid "Swedish"
msgstr ""
msgstr "Suec"
#: pretix/_base_settings.py
msgid "Spanish"
@@ -149,11 +149,11 @@ msgstr "Espanyol"
#: pretix/_base_settings.py
msgid "Spanish (Latin America)"
msgstr ""
msgstr "Espanyol (Llatinoamèrica)"
#: pretix/_base_settings.py
msgid "Thai"
msgstr ""
msgstr "Tailandès"
#: pretix/_base_settings.py
msgid "Turkish"
@@ -161,13 +161,15 @@ msgstr "Turc"
#: pretix/_base_settings.py
msgid "Ukrainian"
msgstr ""
msgstr "Ucraïnès"
#: pretix/api/auth/devicesecurity.py
msgid ""
"Full device access (reading and changing orders and gift cards, reading of "
"products and settings)"
msgstr ""
"Accés total al dispositiu (lectura i modificació de comandes i targetes "
"regal, lectura de productes i configuració)"
#: pretix/api/auth/devicesecurity.py
msgid "pretixSCAN"
@@ -309,7 +311,7 @@ msgstr "La cistella és buida."
#: pretix/api/serializers/item.py
msgid "The program end must not be empty."
msgstr ""
msgstr "El final del programa no ha de ser buit."
#: pretix/api/serializers/item.py pretix/base/models/items.py
#, fuzzy
@@ -339,7 +341,7 @@ msgstr ""
#: pretix/api/serializers/item.py
msgid "Only admission products can currently be personalized."
msgstr ""
msgstr "Actualment, només es poden personalitzar els productes d'accés."
#: pretix/api/serializers/item.py
msgid ""
@@ -434,7 +436,7 @@ msgstr ""
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
#, python-format
msgid "You've been invited to join %(organizer)s"
msgstr ""
msgstr "Has estat convidat/da a unir-te a %(organizer)s"
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
msgid "This user already has been invited for this team."
@@ -456,7 +458,7 @@ msgstr ""
#: pretix/api/views/checkin.py
msgid "Medium connected to other event"
msgstr ""
msgstr "Mitjà connectat a un altre esdeveniment"
#: pretix/api/views/checkin.py
#, fuzzy
@@ -642,6 +644,8 @@ msgid ""
"This includes product added or deleted and changes to nested objects like "
"variations or bundles."
msgstr ""
"Això inclou productes afegits o eliminats i canvis en objectes imbricats, "
"com ara variacions o lots."
#: pretix/api/webhooks.py
#, fuzzy
@@ -654,6 +658,9 @@ msgid ""
"This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability."
msgstr ""
"Això inclou esdeveniments relacionats, com ara la creació, l'eliminació, "
"l'obertura o el tancament de quotes. No s'envia cap webhook per als canvis "
"en la disponibilitat resultant."
#: pretix/api/webhooks.py
#, fuzzy
+5 -5
View File
@@ -5,7 +5,7 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-09-14 23:00+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/"
"de/>\n"
@@ -14,7 +14,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.8.1\n"
"X-Generator: Weblate 2026.9.1\n"
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
#: pretix/_base_settings.py
@@ -11708,7 +11708,7 @@ msgid ""
msgstr ""
"Hallo,\n"
"\n"
"Sie erhalten diese Nachricht weil Sie einen neuen Link zu Ihrer Bestellung "
"Sie erhalten diese Nachricht, weil Sie einen neuen Link zu Ihrer Bestellung "
"für\n"
"{event} angefordert haben.\n"
"\n"
@@ -11738,8 +11738,8 @@ msgid ""
msgstr ""
"Hallo,\n"
"\n"
"Sie erhalten diese Nachricht weil Sie einen neuen Link zu Ihren Bestellungen "
"für\n"
"Sie erhalten diese Nachricht, weil Sie einen neuen Link zu Ihren "
"Bestellungen für\n"
"{event} angefordert haben. Sie finden Ihre Bestellungen unter folgenden "
"Links:\n"
"\n"
+7 -7
View File
@@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-08-24 14:30+0000\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Albizuri <oier@puntu.eus>\n"
"Language-Team: Basque <https://translate.pretix.eu/projects/pretix/pretix/eu/"
">\n"
"Language-Team: Basque <https://translate.pretix.eu/projects/pretix/pretix/"
"eu/>\n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.8.1\n"
"X-Generator: Weblate 2026.9\n"
#: pretix/_base_settings.py
msgid "English"
@@ -891,7 +891,7 @@ msgstr "Produktuaren data"
#: pretix/control/templates/pretixcontrol/order/index.html
#: pretix/presale/templates/pretixpresale/event/order.html
msgid "Order details"
msgstr ""
msgstr "Eskaeraren xehetasunak"
#: pretix/base/datasync/sourcefields.py pretix/base/modelimport_orders.py
#: pretix/control/forms/filter.py
@@ -17296,7 +17296,7 @@ msgstr ""
#: pretix/control/logdisplay.py
msgid "The order details have been changed."
msgstr ""
msgstr "Eskaeraren xehetasunak aldatu dira."
#: pretix/control/logdisplay.py
msgid "The order has been marked as unpaid."
@@ -23106,7 +23106,7 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/order/index.html
#, python-format
msgid "Order details: %(code)s"
msgstr ""
msgstr "Eskaeraren xehetasunak: %(code)s"
#: pretix/control/templates/pretixcontrol/order/index.html
#: pretix/control/templates/pretixcontrol/orders/index.html
+51 -70
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-08-16 22:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"PO-Revision-Date: 2026-09-07 18:00+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n"
"Language: ja\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.8.1\n"
"X-Generator: Weblate 2026.9\n"
#: pretix/_base_settings.py
msgid "English"
@@ -93,7 +93,7 @@ msgstr "ヘブライ語"
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr ""
msgstr "ハンガリー語"
#: pretix/_base_settings.py
msgid "Indonesian"
@@ -220,7 +220,7 @@ msgstr "ターゲットURL"
#: pretix/api/models.py pretix/base/models/devices.py
#: pretix/base/models/organizer.py
msgid "All events (including newly created ones)"
msgstr "すべてのイベント(最近作成されたイベントを含む)"
msgstr "すべてのイベント(今後作成されるものを含む)"
#: pretix/api/models.py pretix/base/models/devices.py
#: pretix/base/models/organizer.py
@@ -428,10 +428,8 @@ msgid "You cannot exchange a medium for a medium."
msgstr "メディアを別のメディアに変更することはできません。"
#: pretix/api/views/checkin.py
#, fuzzy
#| msgid "Product does not support medium exchange."
msgid "You cannot simulate a medium exchange."
msgstr "本製品は中程度の交換に対応していません。"
msgstr "メディアの交換をシミュレートすることはできません。"
#: pretix/api/views/oauth.py pretix/control/logdisplay.py
#, python-brace-format
@@ -5691,7 +5689,7 @@ msgstr "国コード(ISO 3166-1 alpha-2)"
#: pretix/base/models/items.py
msgid "Asked on"
msgstr ""
msgstr "質問日"
#: pretix/base/models/items.py pretix/base/models/organizer.py
msgid ""
@@ -7340,12 +7338,9 @@ msgid "The payment for this invoice has already been received."
msgstr "この請求書の支払いはすでに受領済みです。"
#: pretix/base/payment.py
#, fuzzy
#| msgid ""
#| "This payment is already being processed and can not be canceled any more."
msgid ""
"This payment is already being processed and cannot be canceled any more."
msgstr "この支払いは処理中のため、キャンセルできません。"
msgstr "この支払いはすでに処理中のため、キャンセルできません。"
#: pretix/base/payment.py
msgid "Automatic refunds are not supported by this payment provider."
@@ -8105,16 +8100,12 @@ msgid "Presale end"
msgstr "前売り終了"
#: pretix/base/reldate.py
#, fuzzy
#| msgid "Order email"
msgid "Order creation"
msgstr "注文者メール"
msgstr "注文の作成"
#: pretix/base/reldate.py
#, fuzzy
#| msgid "Order expired"
msgid "Order expiry"
msgstr "注文の有効期限が切れました"
msgstr "注文の失効"
#: pretix/base/reldate.py
msgid "before"
@@ -8143,22 +8134,22 @@ msgstr "未設定"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative date cannot be expressed as \"before\" for \"{}\""
msgstr ""
msgstr "相対的な日付は、「{}」に対して「before」で表すことはできません"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative date cannot be expressed as \"after\" for \"{}\""
msgstr ""
msgstr "相対的な日付は「{}」に対して「after」として表すことはできません"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative time cannot be expressed as \"before\" for \"{}\""
msgstr ""
msgstr "相対時間は「{}」に対して「before」で表すことはできません"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative time cannot be expressed as \"after\" for \"{}\""
msgstr ""
msgstr "相対時間は「{}」に対して「after」で表すことはできません"
#: pretix/base/secrets.py
msgid "Random (default, works with all pretix apps)"
@@ -10067,12 +10058,12 @@ msgstr ""
#: pretix/base/settings.py
msgid "No dates match your criteria."
msgstr ""
msgstr "条件に合致する日付はありません。"
#: pretix/base/settings.py
msgctxt "subevents"
msgid "Text for empty date results"
msgstr ""
msgstr "空の日付結果のテキスト"
#: pretix/base/settings.py
msgctxt "subevents"
@@ -10083,6 +10074,10 @@ msgid ""
"touch with you to arrange further dates. We do not recommend more than one "
"or two sentences."
msgstr ""
"このテキストは、カレンダーまたは日付リストが空の場合に表示されます。たとえば"
"、月に日付が含まれていない場合や、ユーザーが選択したフィルターに結果が見つか"
"らない場合です。これをご利用いただくことで、今後の日程調整のために連絡する方"
"法を宣伝できます。1文または2文以上は推奨いたしません。"
#: pretix/base/settings.py
msgid "Guidance text"
@@ -13902,26 +13897,20 @@ msgid ""
msgstr "ギフトカードの有効期限を発行年を含めた{}年に設定しました。"
#: pretix/control/forms/event.py
#, fuzzy
#| msgid "Payment date"
msgid "Payment term"
msgstr "支払い日"
msgstr "支払条件"
#: pretix/control/forms/event.py
msgid "same as above"
msgstr ""
msgstr "上記と同じ"
#: pretix/control/forms/event.py
#, fuzzy
#| msgid "Payment term in days"
msgid "different payment term in days"
msgstr "支払い条件(日数)"
msgstr "日数で表す異なる支払条件"
#: pretix/control/forms/event.py
#, fuzzy
#| msgid "Payment term in minutes"
msgid "different payment term in minutes"
msgstr "支払い期限(分単位)"
msgstr "分で表す異なる支払条件"
#: pretix/control/forms/event.py
msgid "Prices including tax"
@@ -22533,28 +22522,24 @@ msgstr ""
"食事を提供する場合、ユーザーに食事制限について尋ねることができる一例です。"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "Create a new question"
msgid "Create a new per-ticket question"
msgstr "新しい質問を作成"
msgstr "チケットごとに新しい質問を作成"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "Create a new question"
msgid "Create a new order-level question"
msgstr "新しい質問を作成"
msgstr "注文ごとに新しい質問を作成"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "Delete question"
msgid "Per-ticket questions"
msgstr "質問を削除"
msgstr "チケットごとの質問"
#: pretix/control/templates/pretixcontrol/items/questions.html
msgid ""
"These questions are asked for every ticket, so possibly multiple times in "
"the same order."
msgstr ""
"これらの質問はすべてのチケットに対して尋ねられるため、同じ順序で複数回になる"
"可能性があります。"
#: pretix/control/templates/pretixcontrol/items/questions.html
msgid "Create a new question"
@@ -22573,28 +22558,24 @@ msgid "All personalized products"
msgstr "すべてのパーソナライズされた製品"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "Include questions"
msgid "Per-order questions"
msgstr "質問を含む"
msgstr "注文ごとの質問"
#: pretix/control/templates/pretixcontrol/items/questions.html
msgid ""
"This functionality is in active development and expected to change "
"significantly over the coming months."
msgstr ""
msgstr "この機能は現在開発が活発で、今後数か月で大幅に変化すると予想されています。"
#: pretix/control/templates/pretixcontrol/items/questions.html
msgid ""
"Per-order questions are currently not supported and will not be displayed in "
"pretixPOS."
msgstr ""
msgstr "注文ごとの質問は現在サポートされておらず、pretixPOSでは表示されません。"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "The question has been reordered."
msgid "These questions are asked once per order."
msgstr "質問の並び順が変更されました。"
msgstr "これらの質問は、注文ごとに1回ずつ尋ねられます。"
#: pretix/control/templates/pretixcontrol/items/quota.html
#: pretix/control/templates/pretixcontrol/items/quota_edit.html
@@ -23186,10 +23167,8 @@ msgstr "(任意)"
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
#: pretix/presale/templates/pretixpresale/event/order_modify.html
#, fuzzy
#| msgid "Additional information"
msgid "Additional order information"
msgstr "追加情報"
msgstr "追加の注文情報"
#: pretix/control/templates/pretixcontrol/order/delete.html
msgid "Delete order"
@@ -24856,14 +24835,13 @@ msgid "Hardware model"
msgstr "ハードウェアの機種"
#: pretix/control/templates/pretixcontrol/organizers/devices.html
#, fuzzy, python-format
#| msgid "Begin: %(time)s"
#, python-format
msgid "Last seen: %(time)s"
msgstr "開始: %(time)s"
msgstr "最後の閲覧: %(time)s"
#: pretix/control/templates/pretixcontrol/organizers/devices.html
msgid "No recent contact"
msgstr ""
msgstr "最近の連絡なし"
#: pretix/control/templates/pretixcontrol/organizers/devices.html
msgid "Not yet initialized"
@@ -28220,19 +28198,14 @@ msgstr ""
"に高いことを意味します。ドメインのDNS設定を更新すべきです。"
#: pretix/control/views/mailsetup.py
#, fuzzy
#| msgid ""
#| "We did not find DMARC record for your domain. This means that there is a "
#| "very high chance most of the emails will be rejected or marked as spam. "
#| "You should update the DNS settings of your domain."
msgid ""
"We did not find a DMARC record for your domain. This means that there is a "
"very high chance most of the emails will be rejected or marked as spam. You "
"should update the DNS settings of your domain."
msgstr ""
"お客様のドメインのDMARCレコードが見つかりませんでした。これは、ほとんどのメー"
"ルが拒否されたりスパムとしてマークされたりする可能性が非常に高いことを意味し"
"ます。ドメインのDNS設定を更新すべきです。"
"あなたのドメインのDMARCレコードが見つかりませんでした。これは、ほとんどの"
"メールが拒否されたかスパムとしてマークされた可能性が非常に高いことを意味しま"
"す。ドメインのDNS設定を更新すべきです。"
#: pretix/control/views/mailsetup.py
msgid "The verification code was incorrect, please try again."
@@ -30906,7 +30879,7 @@ msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "Allow further payments during compliance hold"
msgstr ""
msgstr "コンプライアンス保留の間、追加の支払いを許可する"
#: pretix/plugins/paypal2/payment.py
msgid ""
@@ -30916,16 +30889,20 @@ msgid ""
"attempts during that window. This might result in them being charged twice "
"if the original payment is approved."
msgstr ""
"PayPalの不正防止は、個々の支払いの処理をかなりの期間ブロックする可能性があり"
"ます。この期間中、支払いは「保留中」とマークされています。顧客がその期間中に"
"別の支払い試行を開始できるように許可することができます。元の支払いが承認され"
"た場合、二重に請求される可能性があります。"
#: pretix/plugins/paypal2/payment.py
msgid "Timeout further payment attempts"
msgstr ""
msgstr "追加の支払い試行がタイムアウト"
#: pretix/plugins/paypal2/payment.py
msgid ""
"Time duration in minutes after which another payment attempt is possible, "
"while the last payment is still under investigation."
msgstr ""
msgstr "最後の支払いがまだ調査中である間、別の支払い試行が可能となる時間(分)。"
#: pretix/plugins/paypal2/payment.py
msgid "-- Automatic --"
@@ -31190,6 +31167,10 @@ msgid ""
"twice in case PayPal allows your initial payment attempt. Please contact us "
"to resolve this case."
msgstr ""
"支払いはPayPalで処理されています。通常より時間がかかります。PayPalが支払いを"
"確認するまでお待ちいただくか、こちらまたは別の支払い方法で再度お支払いをお試"
"しください。PayPalが最初の支払い試行を許可した場合、二重に請求される可能性が"
"あります。この件を解決するために、弊社までご連絡ください。"
#: pretix/plugins/paypal2/views.py
msgid ""
+11 -6
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-03-30 21:00+0000\n"
"PO-Revision-Date: 2026-09-02 08:35+0000\n"
"Last-Translator: Renne Rocha <renne@rocha.dev.br>\n"
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"
"pretix/pretix/pt_BR/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.16.2\n"
"X-Generator: Weblate 2026.8.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -93,7 +93,7 @@ msgstr "Hebraico"
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr ""
msgstr "Húngaro"
#: pretix/_base_settings.py
msgid "Indonesian"
@@ -153,7 +153,7 @@ msgstr "Espanhol (América latina)"
#: pretix/_base_settings.py
msgid "Thai"
msgstr ""
msgstr "Tailandês"
#: pretix/_base_settings.py
msgid "Turkish"
@@ -3022,11 +3022,12 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"."
msgstr ""
"O campo \"%(label)s\" não pode conter caracteres especiais como %(chars)s\"."
#: pretix/base/forms/questions.py
#, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr ""
msgstr "O campo \"%(label)s\" não pode conter uma URL (%(url)s)."
#: pretix/base/forms/questions.py
msgctxt "phonenumber"
@@ -8173,7 +8174,7 @@ msgstr "Permitida"
#: pretix/base/permissions.py
msgctxt "permission_level"
msgid "Access existing events"
msgstr ""
msgstr "Acessar eventos existentes"
#: pretix/base/permissions.py
msgctxt "permission_level"
@@ -13607,11 +13608,15 @@ msgstr "Por favor, continue em uma nova aba"
#: pretix/base/templates/pretixbase/framebreak.html
msgid "For security reasons, the following step is only possible in a new tab."
msgstr ""
"Por razões de segurança, o passo seguinte só é possível de ser feito em uma "
"nova aba."
#: pretix/base/templates/pretixbase/framebreak.html
msgid ""
"If the new tab did not open automatically, please click the following button:"
msgstr ""
"Se uma nova aba não abrir automaticamente, por favor, clique no botão a "
"seguir:"
#: pretix/base/templates/pretixbase/framebreak.html
#: pretix/presale/templates/pretixpresale/event/cookies.html
+5 -4
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-08-09 06:00+0000\n"
"Last-Translator: Julien <julien@circusiloveyou.com>\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix/"
"sv/>\n"
"Language: sv\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.8.1\n"
"X-Generator: Weblate 2026.9\n"
#: pretix/_base_settings.py
msgid "English"
@@ -19170,8 +19170,9 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/order_pay.html
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
#: pretix/presale/templates/pretixpresale/event/position_change.html
#, fuzzy
msgid "Continue"
msgstr "Fortsätta"
msgstr "Fortsätt"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
msgid "Authorize an application"
+5 -4
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-06 15:52+0000\n"
"PO-Revision-Date: 2026-08-09 06:00+0000\n"
"Last-Translator: Julien <julien@circusiloveyou.com>\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/sv/>\n"
"Language: sv\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.8.1\n"
"X-Generator: Weblate 2026.9\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -133,8 +133,9 @@ msgstr "Mercado Pago"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
#: pretix/static/pretixpresale/js/ui/cart.js
#, fuzzy
msgid "Continue"
msgstr "Fortsätta"
msgstr "Fortsätt"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -55,7 +55,6 @@ from django.db.models.functions import Cast, Coalesce
from django.utils.timezone import make_aware
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pypdf import PageObject, PdfReader, PdfWriter, Transformation
from pypdf.generic import RectangleObject
from reportlab.lib import pagesizes
from reportlab.lib.units import inch, mm
from reportlab.pdfgen import canvas
@@ -238,15 +237,8 @@ def _render_nup_page(nup_pdf: PdfWriter, input_pages: PageObject, opt: dict) ->
di = i % badges_per_page
tx = opt['margins'][3] + (di % opt['cols']) * opt['offsets'][0]
ty = opt['margins'][2] + (opt['rows'] - 1 - (di // opt['cols'])) * opt['offsets'][1]
page.add_transformation(Transformation().translate(tx, ty))
page.mediabox = RectangleObject((
Decimal('%.5f' % (page.mediabox.left.as_numeric() + tx)),
Decimal('%.5f' % (page.mediabox.bottom.as_numeric() + ty)),
Decimal('%.5f' % (page.mediabox.right.as_numeric() + tx)),
Decimal('%.5f' % (page.mediabox.top.as_numeric() + ty))
))
page.trimbox = page.cropbox = page.mediabox
nup_page.merge_page(page)
nup_page.merge_transformed_page(page, Transformation().translate(tx, ty))
return nup_page
@@ -1,183 +1,183 @@
/*global $, gettext*/
/* global gettext */
var bankimport_transactionlist = {
_btn_click: function (e) {
console.log(e.delegateTarget);
var trans_id = parseInt($(e.delegateTarget).attr("name").split("_")[1]);
var value = $(e.delegateTarget).val();
if (value === "discard") {
bankimport_transactionlist.discard(trans_id);
} else if (value === "accept") {
bankimport_transactionlist.accept(trans_id);
} else if (value === "retry") {
bankimport_transactionlist.retry(trans_id);
} else if (value === "assign") {
bankimport_transactionlist.assign(trans_id);
}
return false;
},
_btn_click: function (e) {
console.log(e.delegateTarget)
let trans_id = parseInt($(e.delegateTarget).attr('name').split('_')[1])
let value = $(e.delegateTarget).val()
if (value === 'discard') {
bankimport_transactionlist.discard(trans_id)
} else if (value === 'accept') {
bankimport_transactionlist.accept(trans_id)
} else if (value === 'retry') {
bankimport_transactionlist.retry(trans_id)
} else if (value === 'assign') {
bankimport_transactionlist.assign(trans_id)
}
return false
},
_action: function (id, action, success) {
$("tr[data-id=" + id + "] button").prop("disabled", true);
var data = {
"csrfmiddlewaretoken": $("[name=csrfmiddlewaretoken]").val()
};
data["action_" + id] = action;
$.ajax({
"method": "POST",
"url": $(".transaction-list").attr("data-url"),
"data": data,
"dataType": "json",
"success": function (data) {
if (data.status == "ok") {
$("tr[data-id=" + id + "]").removeClass("has-error");
if (data.comment) {
bankimport_transactionlist.comment_reset_to_text(id, data.comment, data.plain);
}
success();
} else {
$("tr[data-id=" + id + "] button").prop("disabled", false);
$("tr[data-id=" + id + "] .help-block").remove();
$("tr[data-id=" + id + "]").addClass("has-error");
$("<p>").addClass("help-block").text(data.message).appendTo($("tr[data-id=" + id + "] td.actions"));
}
}
});
},
_action: function (id, action, success) {
$('tr[data-id=' + id + '] button').prop('disabled', true)
let data = {
csrfmiddlewaretoken: $('[name=csrfmiddlewaretoken]').val()
}
data['action_' + id] = action
$.ajax({
method: 'POST',
url: $('.transaction-list').attr('data-url'),
data: data,
dataType: 'json',
success: function (data) {
if (data.status == 'ok') {
$('tr[data-id=' + id + ']').removeClass('has-error')
if (data.comment) {
bankimport_transactionlist.comment_reset_to_text(id, data.comment, data.plain)
}
success()
} else {
$('tr[data-id=' + id + '] button').prop('disabled', false)
$('tr[data-id=' + id + '] .help-block').remove()
$('tr[data-id=' + id + ']').addClass('has-error')
$('<p>').addClass('help-block').text(data.message).appendTo($('tr[data-id=' + id + '] td.actions'))
}
}
})
},
discard: function (id) {
bankimport_transactionlist._action(id, "discard", function () {
$("tr[data-id=" + id + "] td").remove();
});
},
discard: function (id) {
bankimport_transactionlist._action(id, 'discard', function () {
$('tr[data-id=' + id + '] td').remove()
})
},
retry: function (id) {
bankimport_transactionlist._action(id, "retry", function () {
$("tr[data-id=" + id + "] td.actions").html('').text(gettext("Marked as paid"));
});
},
retry: function (id) {
bankimport_transactionlist._action(id, 'retry', function () {
$('tr[data-id=' + id + '] td.actions').html('').text(gettext('Marked as paid'))
})
},
accept: function (id) {
bankimport_transactionlist._action(id, "accept", function () {
$("tr[data-id=" + id + "] td.actions").html('').text(gettext("Marked as paid"));
});
},
accept: function (id) {
bankimport_transactionlist._action(id, 'accept', function () {
$('tr[data-id=' + id + '] td.actions').html('').text(gettext('Marked as paid'))
})
},
assign: function (id) {
bankimport_transactionlist._action(id, "assign:" + $("tr[data-id=" + id + "] input.form-control:not(.tt-hint)").val(), function () {
$("tr[data-id=" + id + "] td.actions").html('').text(gettext("Marked as paid"));
});
},
assign: function (id) {
bankimport_transactionlist._action(id, 'assign:' + $('tr[data-id=' + id + '] input.form-control:not(.tt-hint)').val(), function () {
$('tr[data-id=' + id + '] td.actions').html('').text(gettext('Marked as paid'))
})
},
comment_reset_to_text: function (id, text, plain) {
var $box = $("tr[data-id=" + id + "] .comment-box");
$box[0].dataset["plain"] = plain;
$box.html("")
.append($("<strong>").text(gettext("Comment:")))
.append(" ")
.append($("<span>").addClass("comment").append(" ").append(text))
.append(" ")
.append($("<a>").addClass("comment-modify btn btn-default btn-xs")
.append("<span class='fa fa-edit'></span>"));
},
comment_reset_to_text: function (id, text, plain) {
let $box = $('tr[data-id=' + id + '] .comment-box')
$box[0].dataset['plain'] = plain
$box.html('')
.append($('<strong>').text(gettext('Comment:')))
.append(' ')
.append($('<span>').addClass('comment').append(' ').append(text))
.append(' ')
.append($('<a>').addClass('comment-modify btn btn-default btn-xs')
.append('<span class=\'fa fa-edit\'></span>'))
},
comment_start_edit: function (e) {
var $box = $(e.target).closest("div");
var id = $box.closest("tr").attr("data-id");
var $inp = $("<textarea>").addClass("form-control");
var orig_rendered = $box.find(".comment");
var orig_text = $box[0].dataset.plain;
$inp.val(orig_text);
comment_start_edit: function (e) {
let $box = $(e.target).closest('div')
let id = $box.closest('tr').attr('data-id')
let $inp = $('<textarea>').addClass('form-control')
let orig_rendered = $box.find('.comment')
let orig_text = $box[0].dataset.plain
$inp.val(orig_text)
var $btngrp = $("<div>");
$btngrp.addClass("btn-group");
var $btn1 = $("<button>");
$btn1.attr("type", "button").addClass("btn btn-default");
$btn1.append("<span class='fa fa-check'></span>");
$btngrp.append($btn1);
var $btn2 = $("<button>");
$btn2.attr("type", "button").addClass("btn btn-default");
$btn2.append("<span class='fa fa-close'></span>");
$btngrp.append($btn2);
$box.html("").append($inp).append($btngrp);
$btn1.click(function () {
var text = $box.find("textarea").val();
$box.find("input, textarea, button").prop("disabled", true);
bankimport_transactionlist._action(id, "comment:" + text, function () {
$("tr[data-id=" + id + "] button").prop("disabled", false);
});
});
$btn2.click(function () {
bankimport_transactionlist.comment_reset_to_text(id, orig_rendered, orig_text);
});
let $btngrp = $('<div>')
$btngrp.addClass('btn-group')
let $btn1 = $('<button>')
$btn1.attr('type', 'button').addClass('btn btn-default')
$btn1.append('<span class=\'fa fa-check\'></span>')
$btngrp.append($btn1)
let $btn2 = $('<button>')
$btn2.attr('type', 'button').addClass('btn btn-default')
$btn2.append('<span class=\'fa fa-close\'></span>')
$btngrp.append($btn2)
$box.html('').append($inp).append($btngrp)
$btn1.click(function () {
let text = $box.find('textarea').val()
$box.find('input, textarea, button').prop('disabled', true)
bankimport_transactionlist._action(id, 'comment:' + text, function () {
$('tr[data-id=' + id + '] button').prop('disabled', false)
})
})
$btn2.click(function () {
bankimport_transactionlist.comment_reset_to_text(id, orig_rendered, orig_text)
})
e.preventDefault();
},
e.preventDefault()
},
typeahead_source: function () {
return new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: $(".transaction-list").attr("data-url"),
prepare: function (query, settings) {
settings.url = settings.url + '?query=' + encodeURIComponent(query);
return settings;
},
transform: function (object) {
var results = object.results;
var suggs = [];
var reslen = results.length;
for (var i = 0; i < reslen; i++) {
suggs.push(results[i]);
}
return suggs;
}
}
});
},
typeahead_source: function () {
return new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: $('.transaction-list').attr('data-url'),
prepare: function (query, settings) {
settings.url = settings.url + '?query=' + encodeURIComponent(query)
return settings
},
transform: function (object) {
let results = object.results
let suggs = []
let reslen = results.length
for (let i = 0; i < reslen; i++) {
suggs.push(results[i])
}
return suggs
}
}
})
},
init: function () {
if ($(".transaction-list").length) {
$(".transaction-list button").click(bankimport_transactionlist._btn_click);
init: function () {
if ($('.transaction-list').length) {
$('.transaction-list button').click(bankimport_transactionlist._btn_click)
$(".transaction-list").on("click", ".comment-modify", bankimport_transactionlist.comment_start_edit);
$('.transaction-list').on('click', '.comment-modify', bankimport_transactionlist.comment_start_edit)
$(".transaction-list .form-control").typeahead(null, {
minLength: 2,
name: 'order-dataset',
source: bankimport_transactionlist.typeahead_source(),
display: function (obj) {
return obj.code;
},
templates: {
suggestion: function (obj) {
return '<div>' + obj.code + ' (' + obj.total + ', ' + obj.status + ')</div>';
}
}
}).keypress(function (e) {
if (e.keyCode === 13) {
$(this).parent().parent().find("button[value=assign]").click();
}
});
}
$('.transaction-list .form-control').typeahead(null, {
minLength: 2,
name: 'order-dataset',
source: bankimport_transactionlist.typeahead_source(),
display: function (obj) {
return obj.code
},
templates: {
suggestion: function (obj) {
return '<div>' + obj.code + ' (' + obj.total + ', ' + obj.status + ')</div>'
}
}
}).keypress(function (e) {
if (e.keyCode === 13) {
$(this).parent().parent().find('button[value=assign]').click()
}
})
}
if ($("[data-job-waiting]").length) {
window.setTimeout(bankimport_transactionlist.check_state, 750);
}
},
if ($('[data-job-waiting]').length) {
window.setTimeout(bankimport_transactionlist.check_state, 750)
}
},
check_state: function () {
$.getJSON($("[data-job-waiting-url]").attr("data-job-waiting-url"), function (data) {
if (data.state == 'running' || data.state == 'pending') {
window.setTimeout(bankimport_transactionlist.check_state, 750);
} else {
location.reload();
}
});
}
};
check_state: function () {
$.getJSON($('[data-job-waiting-url]').attr('data-job-waiting-url'), function (data) {
if (data.state == 'running' || data.state == 'pending') {
window.setTimeout(bankimport_transactionlist.check_state, 750)
} else {
location.reload()
}
})
}
}
$(function () {
bankimport_transactionlist.init();
});
bankimport_transactionlist.init()
})
-12
View File
@@ -19,15 +19,3 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
-53
View File
@@ -1,53 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import hashlib
import json
from django.core.cache import cache
from paypalrestsdk.api import Api as VendorApi
class Api(VendorApi):
def get_token_hash(self, authorization_code=None, refresh_token=None, headers=None):
if not authorization_code and not refresh_token:
checksum = hashlib.sha256(self.basic_auth().encode()).hexdigest()
cache_key_hash = f'pretix_paypal_token_hash_{checksum}'
cache_key_request_at = f'pretix_paypal_token_request_at_{checksum}'
token_hash = cache.get(cache_key_hash)
if token_hash:
token_request_at = cache.get(cache_key_request_at)
if token_request_at:
self.token_hash = json.loads(token_hash)
self.token_request_at = token_request_at
self.validate_token_hash()
if self.token_hash is not None:
return self.token_hash
t = super().get_token_hash(authorization_code, refresh_token, headers)
if self.token_hash:
cache.set(cache_key_hash, json.dumps(self.token_hash), 3600 * 4)
cache.set(cache_key_request_at, self.token_request_at, 3600 * 4)
return t
return super().get_token_hash(authorization_code, refresh_token, headers)
-69
View File
@@ -1,69 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django.apps import AppConfig
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class PaypalApp(AppConfig):
name = 'pretix.plugins.paypal'
verbose_name = _("PayPal")
class PretixPluginMeta:
name = _("PayPal")
author = _("the pretix team")
version = version
category = 'PAYMENT'
featured = True
picture = 'pretixplugins/paypal/paypal_logo.svg'
description = _("Accept payments with your PayPal account. PayPal is one of the most popular payment methods "
"world-wide.")
def ready(self):
from . import signals # NOQA
def is_available(self, event):
return 'pretix.plugins.paypal' in event.plugins.split(',')
@cached_property
def compatibility_errors(self):
errs = []
try:
import paypalrestsdk # NOQA
except ImportError:
errs.append("Python package 'paypalrestsdk' is not installed.")
return errs
@@ -0,0 +1,21 @@
# Generated by Django 5.2.17 on 2026-09-08 08:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("paypal", "0004_bigint"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.DeleteModel(
name="ReferencedPayPalObject",
),
],
database_operations=[]
)
]
-715
View File
@@ -1,715 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Jakob Schnell, Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import json
import logging
from collections import OrderedDict
from decimal import Decimal
import paypalrestsdk
import paypalrestsdk.exceptions
from django import forms
from django.contrib import messages
from django.http import HttpRequest
from django.template.loader import get_template
from django.urls import reverse
from django.utils.html import format_html
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _
from i18nfield.strings import LazyI18nString
from paypalrestsdk.exceptions import BadRequest, UnauthorizedAccess
from paypalrestsdk.openid_connect import Tokeninfo
from requests import RequestException
from pretix.base.decimal import round_decimal
from pretix.base.forms import SecretKeySettingsField
from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.settings import SettingsSandbox
from pretix.base.views.redirect import safelink
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.paypal.api import Api
from pretix.plugins.paypal.models import ReferencedPayPalObject
logger = logging.getLogger('pretix.plugins.paypal')
SUPPORTED_CURRENCIES = ['AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', 'INR', 'ILS', 'JPY', 'MYR', 'MXN',
'TWD', 'NZD', 'NOK', 'PHP', 'PLN', 'GBP', 'RUB', 'SGD', 'SEK', 'CHF', 'THB', 'USD']
LOCAL_ONLY_CURRENCIES = ['INR']
class Paypal(BasePaymentProvider):
identifier = 'paypal'
verbose_name = _('PayPal')
payment_form_fields = OrderedDict([
])
def __init__(self, event: Event):
super().__init__(event)
self.settings = SettingsSandbox('payment', 'paypal', event)
@property
def test_mode_message(self):
if self.settings.connect_client_id and not self.settings.secret:
# in OAuth mode, sandbox mode needs to be set global
is_sandbox = self.settings.connect_endpoint == 'sandbox'
else:
is_sandbox = self.settings.get('endpoint') == 'sandbox'
if is_sandbox:
return _('The PayPal sandbox is being used, you can test without actually sending money but you will need a '
'PayPal sandbox user to log in.')
return None
@property
def settings_form_fields(self):
if self.settings.connect_client_id and not self.settings.secret:
# PayPal connect
if self.settings.connect_user_id:
fields = [
('connect_user_id',
forms.CharField(
label=_('PayPal account'),
disabled=True
)),
]
else:
return {}
else:
fields = [
('client_id',
forms.CharField(
label=_('Client ID'),
min_length=80,
help_text=format_html(
'<a target="_blank" rel="noopener" href="{docs_url}">{text}</a>',
text=_('Click here for a tutorial on how to obtain the required keys'),
docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html',
)
)),
('secret',
SecretKeySettingsField(
label=_('Secret'),
max_length=80,
min_length=80,
)),
('endpoint',
forms.ChoiceField(
label=_('Endpoint'),
initial='live',
choices=(
('live', 'Live'),
('sandbox', 'Sandbox'),
),
)),
]
extra_fields = [
('prefix',
forms.CharField(
label=_('Reference prefix'),
help_text=_('Any value entered here will be added in front of the regular booking reference '
'containing the order number.'),
required=False,
)),
('postfix',
forms.CharField(
label=_('Reference postfix'),
help_text=_('Any value entered here will be added behind the regular booking reference '
'containing the order number.'),
required=False,
)),
]
d = OrderedDict(
fields + extra_fields + list(super().settings_form_fields.items())
)
d.move_to_end('prefix')
d.move_to_end('postfix')
d.move_to_end('_enabled', False)
return d
def get_connect_url(self, request):
request.session['payment_paypal_oauth_event'] = request.event.pk
self.init_api()
return Tokeninfo.authorize_url({'scope': 'openid profile email'})
def settings_content_render(self, request):
settings_content = ""
if self.settings.connect_client_id and not self.settings.secret:
# Use PayPal connect
if not self.settings.connect_user_id:
# Migrate User to PayPal v2
self.event.disable_plugin("pretix.plugins.paypal")
self.event.enable_plugin("pretix.plugins.paypal2")
self.event.save()
else:
settings_content = (
"<button formaction='{}' class='btn btn-danger'>{}</button>"
).format(
reverse('plugins:paypal:oauth.disconnect', kwargs={
'organizer': self.event.organizer.slug,
'event': self.event.slug,
}),
_('Disconnect from PayPal')
)
else:
# Migrate User to PayPal v2
self.event.disable_plugin("pretix.plugins.paypal")
self.event.enable_plugin("pretix.plugins.paypal2")
self.event.save()
return settings_content
def is_allowed(self, request: HttpRequest, total: Decimal = None) -> bool:
return super().is_allowed(request, total) and self.event.currency in SUPPORTED_CURRENCIES
def init_api(self):
if self.settings.connect_client_id and not self.settings.secret:
paypalrestsdk.api.__api__ = Api(
mode="sandbox" if "sandbox" in self.settings.connect_endpoint else 'live',
client_id=self.settings.connect_client_id,
client_secret=self.settings.connect_secret_key,
openid_client_id=self.settings.connect_client_id,
openid_client_secret=self.settings.connect_secret_key
)
else:
paypalrestsdk.api.__api__ = Api(
mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live',
client_id=self.settings.get('client_id'),
client_secret=self.settings.get('secret')
)
def payment_is_valid_session(self, request):
return (request.session.get('payment_paypal_id', '') != ''
and request.session.get('payment_paypal_payer', '') != '')
def payment_form_render(self, request) -> str:
template = get_template('pretixplugins/paypal/checkout_payment_form.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def checkout_prepare(self, request, cart):
self.init_api()
kwargs = {}
if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
try:
if self.settings.connect_client_id and not self.settings.secret:
if not request.event.settings.payment_paypal_connect_user_id:
raise PaymentException('Payment method misconfigured')
try:
tokeninfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token)
except BadRequest as ex:
ex = json.loads(ex.content)
messages.error(request, '{}: {} ({})'.format(
_('We had trouble communicating with PayPal'),
ex['error_description'],
ex['correlation_id'])
)
return
# Even if the token has been refreshed, calling userinfo() can fail. In this case we just don't
# get the userinfo again and use the payment_paypal_connect_user_id that we already have on file
try:
userinfo = tokeninfo.userinfo()
request.event.settings.payment_paypal_connect_user_id = userinfo.email
except UnauthorizedAccess:
pass
payee = {
"email": request.event.settings.payment_paypal_connect_user_id,
# If PayPal ever offers a good way to get the MerchantID via the Identifity API,
# we should use it instead of the merchant's eMail-address
# "merchant_id": request.event.settings.payment_paypal_connect_user_id,
}
else:
payee = {}
payment = paypalrestsdk.Payment({
'header': {'PayPal-Partner-Attribution-Id': 'ramiioSoftwareentwicklung_SP'},
'intent': 'sale',
'payer': {
"payment_method": "paypal",
},
"redirect_urls": {
"return_url": eventreverse_absolute(request.event, 'plugins:paypal:return', kwargs=kwargs),
"cancel_url": eventreverse_absolute(request.event, 'plugins:paypal:abort', kwargs=kwargs),
},
"transactions": [
{
"item_list": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order for %s') % str(request.event),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(cart['total']),
"currency": request.event.currency
}
]
},
"amount": {
"currency": request.event.currency,
"total": self.format_price(cart['total'])
},
"description": __('Event tickets for {event}').format(event=request.event.name),
"payee": payee,
"custom": '{prefix}{slug}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
slug=request.event.slug.upper(),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
)
}
]
})
request.session['payment_paypal_payment'] = None
return self._create_payment(request, payment)
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
def format_price(self, value):
return str(round_decimal(value, self.event.currency, {
# PayPal behaves differently than Stripe in deciding what currencies have decimal places
# Source https://developer.paypal.com/docs/classic/api/currency_codes/
'HUF': 0,
'JPY': 0,
'MYR': 0,
'TWD': 0,
# However, CLPs are not listed there while PayPal requires us not to send decimal places there. WTF.
'CLP': 0,
# Let's just guess that the ones listed here are 0-based as well
# https://developers.braintreepayments.com/reference/general/currencies
'BIF': 0,
'DJF': 0,
'GNF': 0,
'KMF': 0,
'KRW': 0,
'LAK': 0,
'PYG': 0,
'RWF': 0,
'UGX': 0,
'VND': 0,
'VUV': 0,
'XAF': 0,
'XOF': 0,
'XPF': 0,
}))
@property
def abort_pending_allowed(self):
return False
def _create_payment(self, request, payment):
if payment.create():
if payment.state not in ('created', 'approved', 'pending'):
messages.error(request, _('We had trouble communicating with PayPal'))
logger.error('Invalid payment state: ' + str(payment))
return
request.session['payment_paypal_id'] = payment.id
for link in payment.links:
if link.method == "REDIRECT" and link.rel == "approval_url":
if request.session.get('iframe_session', False):
return safelink(link.href, framebreak=True)
else:
return str(link.href)
else:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.error('Error on creating payment: ' + str(payment.error))
def checkout_confirm_render(self, request) -> str:
"""
Returns the HTML that should be displayed when the user selected this provider
on the 'confirm order' page.
"""
template = get_template('pretixplugins/paypal/checkout_payment_confirm.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
if (request.session.get('payment_paypal_id', '') == '' or request.session.get('payment_paypal_payer', '') == ''):
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
self.init_api()
pp_payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id'))
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_payment.id)
if str(pp_payment.transactions[0].amount.total) != str(payment.amount) or pp_payment.transactions[0].amount.currency \
!= self.event.currency:
logger.error('Value mismatch: Payment %s vs paypal trans %s' % (payment.id, str(pp_payment)))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
return self._execute_payment(pp_payment, request, payment)
def _execute_payment(self, payment, request, payment_obj):
if payment.state == 'created':
payment.replace([
{
"op": "replace",
"path": "/transactions/0/item_list",
"value": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {slug}-{code}').format(
slug=self.event.slug.upper(),
code=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(payment_obj.amount),
"currency": payment_obj.order.event.currency
}
]
}
},
{
"op": "replace",
"path": "/transactions/0/description",
"value": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=request.event.name,
order=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
}
])
try:
payment.execute({"payer_id": request.session.get('payment_paypal_payer')})
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
except RequestException as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
for trans in payment.transactions:
for rr in trans.related_resources:
if hasattr(rr, 'sale') and rr.sale:
if rr.sale.state == 'pending':
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as '
'soon as the payment completed.'))
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING
payment_obj.save()
return
payment_obj.refresh_from_db()
if payment.state == 'pending':
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as soon as the '
'payment completed.'))
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING
payment_obj.save()
return
if payment.state != 'approved':
payment_obj.fail(info=payment.to_dict())
logger.error('Invalid state: %s' % str(payment))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
if payment_obj.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('PayPal success event even though order is already marked as paid')
return
try:
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.save(update_fields=['info'])
payment_obj.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
return None
def payment_pending_render(self, request, payment) -> str:
retry = True
try:
if (
payment.info
and payment.info_data['transactions'][0]['related_resources'][0]['sale']['state'] == 'pending'
):
retry = False
except (KeyError, IndexError):
pass
template = get_template('pretixplugins/paypal/pending.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'retry': retry, 'order': payment.order}
return template.render(ctx)
def matching_id(self, payment: OrderPayment):
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
return sale_id or payment.info_data.get('id', None)
def api_payment_details(self, payment: OrderPayment):
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
return {
"payer_email": payment.info_data.get('payer', {}).get('payer_info', {}).get('email'),
"payer_id": payment.info_data.get('payer', {}).get('payer_info', {}).get('payer_id'),
"cart_id": payment.info_data.get('cart', None),
"payment_id": payment.info_data.get('id', None),
"sale_id": sale_id,
}
def payment_control_render(self, request: HttpRequest, payment: OrderPayment):
template = get_template('pretixplugins/paypal/control.html')
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'payment_info': payment.info_data, 'order': payment.order, 'sale_id': sale_id}
return template.render(ctx)
def payment_control_render_short(self, payment: OrderPayment) -> str:
return payment.info_data.get('payer', {}).get('payer_info', {}).get('email', '')
def payment_partial_refund_supported(self, payment: OrderPayment):
# Paypal refunds are possible for 180 days after purchase:
# https://www.paypal.com/lc/smarthelp/article/how-do-i-issue-a-refund-faq780#:~:text=Refund%20after%20180%20days%20of,PayPal%20balance%20of%20the%20recipient.
return (now() - payment.payment_date).days <= 180
def payment_refund_supported(self, payment: OrderPayment):
self.payment_partial_refund_supported(payment)
def execute_refund(self, refund: OrderRefund):
self.init_api()
try:
sale = None
for res in refund.payment.info_data['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale':
sale = paypalrestsdk.Sale.find(v['id'])
break
if not sale:
pp_payment = paypalrestsdk.Payment.find(refund.payment.info_data['id'])
for res in pp_payment.transactions[0].related_resources:
for k, v in res.to_dict().items():
if k == 'sale':
sale = paypalrestsdk.Sale.find(v['id'])
break
pp_refund = sale.refund({
"amount": {
"total": self.format_price(refund.amount),
"currency": refund.order.event.currency
}
})
except paypalrestsdk.exceptions.ConnectionError as e:
refund.order.log_action('pretix.event.order.refund.failed', {
'local_id': refund.local_id,
'provider': refund.provider,
'error': str(e)
})
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(str(e)))
if not pp_refund.success():
refund.order.log_action('pretix.event.order.refund.failed', {
'local_id': refund.local_id,
'provider': refund.provider,
'error': str(pp_refund.error)
})
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(pp_refund.error))
else:
sale = paypalrestsdk.Payment.find(refund.payment.info_data['id'])
refund.payment.info = json.dumps(sale.to_dict())
refund.info = json.dumps(pp_refund.to_dict())
refund.done()
def payment_prepare(self, request, payment_obj):
self.init_api()
try:
if request.event.settings.payment_paypal_connect_user_id:
try:
tokeninfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token)
except BadRequest as ex:
ex = json.loads(ex.content)
messages.error(request, '{}: {} ({})'.format(
_('We had trouble communicating with PayPal'),
ex['error_description'],
ex['correlation_id'])
)
return
# Even if the token has been refreshed, calling userinfo() can fail. In this case we just don't
# get the userinfo again and use the payment_paypal_connect_user_id that we already have on file
try:
userinfo = tokeninfo.userinfo()
request.event.settings.payment_paypal_connect_user_id = userinfo.email
except UnauthorizedAccess:
pass
payee = {
"email": request.event.settings.payment_paypal_connect_user_id,
# If PayPal ever offers a good way to get the MerchantID via the Identifity API,
# we should use it instead of the merchant's eMail-address
# "merchant_id": request.event.settings.payment_paypal_connect_user_id,
}
else:
payee = {}
payment = paypalrestsdk.Payment({
'header': {'PayPal-Partner-Attribution-Id': 'ramiioSoftwareentwicklung_SP'},
'intent': 'sale',
'payer': {
"payment_method": "paypal",
},
"redirect_urls": {
"return_url": eventreverse_absolute(request.event, 'plugins:paypal:return'),
"cancel_url": eventreverse_absolute(request.event, 'plugins:paypal:abort'),
},
"transactions": [
{
"item_list": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {slug}-{code}').format(
slug=self.event.slug.upper(),
code=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(payment_obj.amount),
"currency": payment_obj.order.event.currency
}
]
},
"amount": {
"currency": request.event.currency,
"total": self.format_price(payment_obj.amount)
},
"description": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=request.event.name,
order=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"payee": payee,
"custom": '{prefix}{slug}-{code}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
slug=self.event.slug.upper(),
code=payment_obj.order.code,
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
}
]
})
request.session['payment_paypal_payment'] = payment_obj.pk
return self._create_payment(request, payment)
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
def shred_payment_info(self, obj):
if obj.info:
d = json.loads(obj.info)
new = {
'id': d.get('id'),
'payer': {
'payer_info': {
'email': '█'
}
},
'update_time': d.get('update_time'),
'transactions': [
{
'amount': t.get('amount')
} for t in d.get('transactions', [])
],
'_shredded': True
}
obj.info = json.dumps(new)
obj.save(update_fields=['info'])
for le in obj.order.all_logentries().filter(action_type="pretix.plugins.paypal.event").exclude(data=""):
d = le.parsed_data
if 'resource' in d:
d['resource'] = {
'id': d['resource'].get('id'),
'sale_id': d['resource'].get('sale_id'),
'parent_payment': d['resource'].get('parent_payment'),
}
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
def render_invoice_text(self, order: Order, payment: OrderPayment) -> str:
if order.status == Order.STATUS_PAID:
if payment.info_data.get('id', None):
try:
return '{}\r\n{}: {}\r\n{}: {}'.format(
_('The payment for this invoice has already been received.'),
_('PayPal payment ID'),
payment.info_data['id'],
_('PayPal sale ID'),
payment.info_data['transactions'][0]['related_resources'][0]['sale']['id']
)
except (KeyError, IndexError):
return '{}\r\n{}: {}'.format(
_('The payment for this invoice has already been received.'),
_('PayPal payment ID'),
payment.info_data['id']
)
else:
return super().render_invoice_text(order, payment)
return self.settings.get('_invoice_text', as_type=LazyI18nString, default='')
-31
View File
@@ -1,31 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.dispatch import receiver
from pretix.base.signals import register_payment_providers
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
def register_payment_provider(sender, **kwargs):
from .payment import Paypal
return Paypal
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="124px" height="33px" viewBox="0 0 124 33" enable-background="new 0 0 124 33" xml:space="preserve">
<path fill="#253B80" d="M46.211,6.749h-6.839c-0.468,0-0.866,0.34-0.939,0.802l-2.766,17.537c-0.055,0.346,0.213,0.658,0.564,0.658 h3.265c0.468,0,0.866-0.34,0.939-0.803l0.746-4.73c0.072-0.463,0.471-0.803,0.938-0.803h2.165c4.505,0,7.105-2.18,7.784-6.5 c0.306-1.89,0.013-3.375-0.872-4.415C50.224,7.353,48.5,6.749,46.211,6.749z M47,13.154c-0.374,2.454-2.249,2.454-4.062,2.454 h-1.032l0.724-4.583c0.043-0.277,0.283-0.481,0.563-0.481h0.473c1.235,0,2.4,0,3.002,0.704C47.027,11.668,47.137,12.292,47,13.154z"/>
<path fill="#253B80" d="M66.654,13.075h-3.275c-0.279,0-0.52,0.204-0.563,0.481l-0.145,0.916l-0.229-0.332 c-0.709-1.029-2.29-1.373-3.868-1.373c-3.619,0-6.71,2.741-7.312,6.586c-0.313,1.918,0.132,3.752,1.22,5.031 c0.998,1.176,2.426,1.666,4.125,1.666c2.916,0,4.533-1.875,4.533-1.875l-0.146,0.91c-0.055,0.348,0.213,0.66,0.562,0.66h2.95 c0.469,0,0.865-0.34,0.939-0.803l1.77-11.209C67.271,13.388,67.004,13.075,66.654,13.075z M62.089,19.449 c-0.316,1.871-1.801,3.127-3.695,3.127c-0.951,0-1.711-0.305-2.199-0.883c-0.484-0.574-0.668-1.391-0.514-2.301 c0.295-1.855,1.805-3.152,3.67-3.152c0.93,0,1.686,0.309,2.184,0.892C62.034,17.721,62.232,18.543,62.089,19.449z"/>
<path fill="#253B80" d="M84.096,13.075h-3.291c-0.314,0-0.609,0.156-0.787,0.417l-4.539,6.686l-1.924-6.425 c-0.121-0.402-0.492-0.678-0.912-0.678h-3.234c-0.393,0-0.666,0.384-0.541,0.754l3.625,10.638l-3.408,4.811 c-0.268,0.379,0.002,0.9,0.465,0.9h3.287c0.312,0,0.604-0.152,0.781-0.408L84.564,13.97C84.826,13.592,84.557,13.075,84.096,13.075z "/>
<path fill="#179BD7" d="M94.992,6.749h-6.84c-0.467,0-0.865,0.34-0.938,0.802l-2.766,17.537c-0.055,0.346,0.213,0.658,0.562,0.658 h3.51c0.326,0,0.605-0.238,0.656-0.562l0.785-4.971c0.072-0.463,0.471-0.803,0.938-0.803h2.164c4.506,0,7.105-2.18,7.785-6.5 c0.307-1.89,0.012-3.375-0.873-4.415C99.004,7.353,97.281,6.749,94.992,6.749z M95.781,13.154c-0.373,2.454-2.248,2.454-4.062,2.454 h-1.031l0.725-4.583c0.043-0.277,0.281-0.481,0.562-0.481h0.473c1.234,0,2.4,0,3.002,0.704 C95.809,11.668,95.918,12.292,95.781,13.154z"/>
<path fill="#179BD7" d="M115.434,13.075h-3.273c-0.281,0-0.52,0.204-0.562,0.481l-0.145,0.916l-0.23-0.332 c-0.709-1.029-2.289-1.373-3.867-1.373c-3.619,0-6.709,2.741-7.311,6.586c-0.312,1.918,0.131,3.752,1.219,5.031 c1,1.176,2.426,1.666,4.125,1.666c2.916,0,4.533-1.875,4.533-1.875l-0.146,0.91c-0.055,0.348,0.213,0.66,0.564,0.66h2.949 c0.467,0,0.865-0.34,0.938-0.803l1.771-11.209C116.053,13.388,115.785,13.075,115.434,13.075z M110.869,19.449 c-0.314,1.871-1.801,3.127-3.695,3.127c-0.949,0-1.711-0.305-2.199-0.883c-0.484-0.574-0.666-1.391-0.514-2.301 c0.297-1.855,1.805-3.152,3.67-3.152c0.93,0,1.686,0.309,2.184,0.892C110.816,17.721,111.014,18.543,110.869,19.449z"/>
<path fill="#179BD7" d="M119.295,7.23l-2.807,17.858c-0.055,0.346,0.213,0.658,0.562,0.658h2.822c0.469,0,0.867-0.34,0.939-0.803 l2.768-17.536c0.055-0.346-0.213-0.659-0.562-0.659h-3.16C119.578,6.749,119.338,6.953,119.295,7.23z"/>
<path fill="#253B80" d="M7.266,29.154l0.523-3.322l-1.165-0.027H1.061L4.927,1.292C4.939,1.218,4.978,1.149,5.035,1.1 c0.057-0.049,0.13-0.076,0.206-0.076h9.38c3.114,0,5.263,0.648,6.385,1.927c0.526,0.6,0.861,1.227,1.023,1.917 c0.17,0.724,0.173,1.589,0.007,2.644l-0.012,0.077v0.676l0.526,0.298c0.443,0.235,0.795,0.504,1.065,0.812 c0.45,0.513,0.741,1.165,0.864,1.938c0.127,0.795,0.085,1.741-0.123,2.812c-0.24,1.232-0.628,2.305-1.152,3.183 c-0.482,0.809-1.096,1.48-1.825,2c-0.696,0.494-1.523,0.869-2.458,1.109c-0.906,0.236-1.939,0.355-3.072,0.355h-0.73 c-0.522,0-1.029,0.188-1.427,0.525c-0.399,0.344-0.663,0.814-0.744,1.328l-0.055,0.299l-0.924,5.855l-0.042,0.215 c-0.011,0.068-0.03,0.102-0.058,0.125c-0.025,0.021-0.061,0.035-0.096,0.035H7.266z"/>
<path fill="#179BD7" d="M23.048,7.667L23.048,7.667L23.048,7.667c-0.028,0.179-0.06,0.362-0.096,0.55 c-1.237,6.351-5.469,8.545-10.874,8.545H9.326c-0.661,0-1.218,0.48-1.321,1.132l0,0l0,0L6.596,26.83l-0.399,2.533 c-0.067,0.428,0.263,0.814,0.695,0.814h4.881c0.578,0,1.069-0.42,1.16-0.99l0.048-0.248l0.919-5.832l0.059-0.32 c0.09-0.572,0.582-0.992,1.16-0.992h0.73c4.729,0,8.431-1.92,9.513-7.476c0.452-2.321,0.218-4.259-0.978-5.622 C24.022,8.286,23.573,7.945,23.048,7.667z"/>
<path fill="#222D65" d="M21.754,7.151c-0.189-0.055-0.384-0.105-0.584-0.15c-0.201-0.044-0.407-0.083-0.619-0.117 c-0.742-0.12-1.555-0.177-2.426-0.177h-7.352c-0.181,0-0.353,0.041-0.507,0.115C9.927,6.985,9.675,7.306,9.614,7.699L8.05,17.605 l-0.045,0.289c0.103-0.652,0.66-1.132,1.321-1.132h2.752c5.405,0,9.637-2.195,10.874-8.545c0.037-0.188,0.068-0.371,0.096-0.55 c-0.313-0.166-0.652-0.308-1.017-0.429C21.941,7.208,21.848,7.179,21.754,7.151z"/>
<path fill="#253B80" d="M9.614,7.699c0.061-0.393,0.313-0.714,0.652-0.876c0.155-0.074,0.326-0.115,0.507-0.115h7.352 c0.871,0,1.684,0.057,2.426,0.177c0.212,0.034,0.418,0.073,0.619,0.117c0.2,0.045,0.395,0.095,0.584,0.15 c0.094,0.028,0.187,0.057,0.278,0.086c0.365,0.121,0.704,0.264,1.017,0.429c0.368-2.347-0.003-3.945-1.272-5.392 C20.378,0.682,17.853,0,14.622,0h-9.38c-0.66,0-1.223,0.48-1.325,1.133L0.01,25.898c-0.077,0.49,0.301,0.932,0.795,0.932h5.791 l1.454-9.225L9.614,7.699z"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1,6 +0,0 @@
{% load i18n %}
<p>{% blocktrans trimmed %}
The total amount listed above will be withdrawn from your PayPal account after the
confirmation of your purchase.
{% endblocktrans %}</p>
@@ -1,6 +0,0 @@
{% load i18n %}
<p>{% blocktrans trimmed %}
After you clicked continue, we will redirect you to PayPal to fill in your payment
details. You will then be redirected back here to review and confirm your order.
{% endblocktrans %}</p>
@@ -1,18 +0,0 @@
{% load i18n %}
{% if payment_info %}
<dl class="dl-horizontal">
<dt>{% trans "Payment ID" %}</dt>
<dd>{{ payment_info.id }}</dd>
<dt>{% trans "Sale ID" %}</dt>
<dd>{{ sale_id|default_if_none:"?" }}</dd>
<dt>{% trans "Payer" %}</dt>
<dd>{{ payment_info.payer.payer_info.email }}</dd>
<dt>{% trans "Last update" %}</dt>
<dd>{{ payment_info.update_time }}</dd>
<dt>{% trans "Total value" %}</dt>
<dd>{{ payment_info.transactions.0.amount.total }}</dd>
<dt>{% trans "Currency" %}</dt>
<dd>{{ payment_info.transactions.0.amount.currency }}</dd>
</dl>
{% endif %}
@@ -1,12 +0,0 @@
{% load i18n %}
{% if retry %}
<p>{% blocktrans trimmed %}
Our attempt to execute your Payment via PayPal has failed. Please try again or contact us.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}
We're waiting for an answer from PayPal regarding your payment. Please contact us, if this
takes more than a few hours.
{% endblocktrans %}</p>
{% endif %}
-39
View File
@@ -1,39 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.urls import include, re_path
from .views import abort, oauth_disconnect, success
event_patterns = [
re_path(r'^paypal/', include([
re_path(r'^abort/$', abort, name='abort'),
re_path(r'^return/$', success, name='return'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/abort/', abort, name='abort'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/return/', success, name='return'),
])),
]
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/paypal/disconnect/',
oauth_disconnect, name='oauth.disconnect'),
]
-249
View File
@@ -1,249 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Flavia Bastos
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import json
import logging
from decimal import Decimal
import paypalrestsdk
import paypalrestsdk.exceptions
from django.contrib import messages
from django.db.models import Sum
from django.http import HttpResponse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django_scopes import scopes_disabled
from pretix.base.models import Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import PaymentException
from pretix.control.permissions import event_permission_required
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal.payment import Paypal
logger = logging.getLogger('pretix.plugins.paypal')
def success(request, *args, **kwargs):
pid = request.GET.get('paymentId')
token = request.GET.get('token')
payer = request.GET.get('PayerID')
request.session['payment_paypal_token'] = token
request.session['payment_paypal_payer'] = payer
urlkwargs = {}
if 'cart_namespace' in kwargs:
urlkwargs['cart_namespace'] = kwargs['cart_namespace']
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
payment = None
if pid == request.session.get('payment_paypal_id', None):
if payment:
prov = Paypal(request.event)
try:
resp = prov.execute_payment(request, payment)
except PaymentException as e:
messages.error(request, str(e))
urlkwargs['step'] = 'payment'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if resp:
return resp
else:
messages.error(request, _('Invalid response from PayPal received.'))
logger.error('Session did not contain payment_paypal_id')
urlkwargs['step'] = 'payment'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if payment:
return redirect_to_url(eventreverse(request.event, 'presale:event.order', kwargs={
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
urlkwargs['step'] = 'confirm'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
def abort(request, *args, **kwargs):
messages.error(request, _('It looks like you canceled the PayPal payment'))
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
payment = None
if payment:
return redirect_to_url(eventreverse(request.event, 'presale:event.order', kwargs={
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
@csrf_exempt
@require_POST
@scopes_disabled()
def webhook(request, *args, **kwargs):
event_body = request.body.decode('utf-8').strip()
event_json = json.loads(event_body)
# We do not check the signature, we just use it as a trigger to look the charge up.
if 'resource_type' not in event_json:
return HttpResponse("Invalid body, no resource_type given", status=400)
if event_json['resource_type'] not in ('sale', 'refund'):
return HttpResponse("Not interested in this resource type", status=200)
if event_json['resource_type'] == 'sale':
saleid = event_json['resource']['id']
else:
saleid = event_json['resource']['sale_id']
try:
refs = [saleid]
if event_json['resource'].get('parent_payment'):
refs.append(event_json['resource'].get('parent_payment'))
rso = ReferencedPayPalObject.objects.select_related('order', 'order__event').get(
reference__in=refs
)
event = rso.order.event
except ReferencedPayPalObject.DoesNotExist:
rso = None
if hasattr(request, 'event'):
event = request.event
else:
return HttpResponse("Unable to detect event", status=200)
prov = Paypal(event)
prov.init_api()
try:
sale = paypalrestsdk.Sale.find(saleid)
except paypalrestsdk.exceptions.ConnectionError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Sale not found', status=500)
if rso and rso.payment:
payment = rso.payment
else:
payments = OrderPayment.objects.filter(order__event=event, provider='paypal',
info__icontains=sale['id'])
payment = None
for p in payments:
payment_info = p.info_data
for res in payment_info['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale' and v['id'] == sale['id']:
payment = p
break
if not payment:
return HttpResponse('Payment not found', status=200)
payment.order.log_action('pretix.plugins.paypal.event', data=event_json)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['state'] in ('partially_refunded', 'refunded'):
if event_json['resource_type'] == 'refund':
try:
refund = paypalrestsdk.Refund.find(event_json['resource']['id'])
except paypalrestsdk.exceptions.ConnectionError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Refund not found', status=500)
known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()}
if refund['id'] not in known_refunds:
payment.create_external_refund(
amount=abs(Decimal(refund['amount']['total'])),
info=json.dumps(refund.to_dict() if not isinstance(refund, dict) else refund)
)
elif known_refunds.get(refund['id']).state in (
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['state'] == 'completed':
known_refunds.get(refund['id']).done()
if 'total_refunded_amount' in refund:
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
total_refunded_amount = Decimal(refund['total_refunded_amount']['value'])
if known_sum < total_refunded_amount:
payment.create_external_refund(
amount=total_refunded_amount - known_sum
)
elif sale['state'] == 'refunded':
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if known_sum < payment.amount:
payment.create_external_refund(
amount=payment.amount - known_sum
)
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED) and sale['state'] == 'completed':
try:
payment.confirm()
except Quota.QuotaExceededException:
pass
return HttpResponse(status=200)
@event_permission_required('event.settings.general:write')
@require_POST
def oauth_disconnect(request, **kwargs):
del request.event.settings.payment_paypal_connect_refresh_token
del request.event.settings.payment_paypal_connect_user_id
request.event.settings.payment_paypal__enabled = False
messages.success(request, _('Your PayPal account has been disconnected.'))
# Migrate User to PayPal v2
event = request.event
event.disable_plugin("pretix.plugins.paypal")
event.enable_plugin("pretix.plugins.paypal2")
event.save()
return redirect_to_url(reverse('control:event.settings.payment.provider', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
'provider': 'paypal_settings'
}))
@@ -0,0 +1,54 @@
# Generated by Django 5.2.17 on 2026-09-08 08:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name="ReferencedPayPalObject",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
(
"reference",
models.CharField(db_index=True, max_length=190, unique=True),
),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.order",
),
),
(
"payment",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.orderpayment",
),
),
],
options={
"db_table": "paypal_referencedpaypalobject",
},
),
],
database_operations=[]
)
]
@@ -26,3 +26,6 @@ class ReferencedPayPalObject(models.Model):
reference = models.CharField(max_length=190, db_index=True, unique=True)
order = models.ForeignKey('pretixbase.Order', on_delete=models.CASCADE)
payment = models.ForeignKey('pretixbase.OrderPayment', null=True, blank=True, on_delete=models.CASCADE)
class Meta:
db_table = 'paypal_referencedpaypalobject'
+1 -1
View File
@@ -68,7 +68,7 @@ from pretix.plugins.paypal2.client.core.paypal_http_client import (
from pretix.plugins.paypal2.client.customer.partner_referral_create_request import (
PartnerReferralCreateRequest,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal2.models import ReferencedPayPalObject
logger = logging.getLogger('pretix.plugins.paypal2')
@@ -1,350 +1,350 @@
/*global $, paypal_client_id, paypal_loadingmessage, gettext */
'use strict';
/* global paypal_client_id, paypal_loadingmessage, gettext */
'use strict'
var pretixpaypal = {
paypal: null,
client_id: null,
order_id: null,
payer_id: null,
merchant_id: null,
currency: null,
method: null,
additional_disabled_funding: null,
additional_enabled_funding: null,
debug_buyer_country: null,
continue_button: null,
paypage: false,
method_map: {
wallet: {
method: 'wallet',
funding_source: 'paypal',
//disable_funding: null,
//enable_funding: 'paylater',
early_auth: true,
},
apm: {
method: 'apm',
funding_source: null,
//disable_funding: null,
//enable_funding: null,
early_auth: false,
}
},
apm_map: {
paypal: gettext('PayPal'),
venmo: gettext('Venmo'),
applepay: gettext('Apple Pay'),
itau: gettext('Itaú'),
credit: gettext('PayPal Credit'),
card: gettext('Credit Card'),
paylater: gettext('PayPal Pay Later'),
ideal: gettext('iDEAL | Wero'),
sepa: gettext('SEPA Direct Debit'),
bancontact: gettext('Bancontact'),
giropay: gettext('giropay'),
sofort: gettext('SOFORT'),
eps: gettext('eps'),
mybank: gettext('MyBank'),
p24: gettext('Przelewy24'),
verkkopankki: gettext('Verkkopankki'),
payu: gettext('PayU'),
blik: gettext('BLIK'),
trustly: gettext('Trustly'),
zimpler: gettext('Zimpler'),
maxima: gettext('Maxima'),
oxxo: gettext('OXXO'),
boleto: gettext('Boleto'),
wechatpay: gettext('WeChat Pay'),
mercadopago: gettext('Mercado Pago')
},
readyToSubmitApproval: false,
paypal: null,
client_id: null,
order_id: null,
payer_id: null,
merchant_id: null,
currency: null,
method: null,
additional_disabled_funding: null,
additional_enabled_funding: null,
debug_buyer_country: null,
continue_button: null,
paypage: false,
method_map: {
wallet: {
method: 'wallet',
funding_source: 'paypal',
// disable_funding: null,
// enable_funding: 'paylater',
early_auth: true,
},
apm: {
method: 'apm',
funding_source: null,
// disable_funding: null,
// enable_funding: null,
early_auth: false,
}
},
apm_map: {
paypal: gettext('PayPal'),
venmo: gettext('Venmo'),
applepay: gettext('Apple Pay'),
itau: gettext('Itaú'),
credit: gettext('PayPal Credit'),
card: gettext('Credit Card'),
paylater: gettext('PayPal Pay Later'),
ideal: gettext('iDEAL | Wero'),
sepa: gettext('SEPA Direct Debit'),
bancontact: gettext('Bancontact'),
giropay: gettext('giropay'),
sofort: gettext('SOFORT'),
eps: gettext('eps'),
mybank: gettext('MyBank'),
p24: gettext('Przelewy24'),
verkkopankki: gettext('Verkkopankki'),
payu: gettext('PayU'),
blik: gettext('BLIK'),
trustly: gettext('Trustly'),
zimpler: gettext('Zimpler'),
maxima: gettext('Maxima'),
oxxo: gettext('OXXO'),
boleto: gettext('Boleto'),
wechatpay: gettext('WeChat Pay'),
mercadopago: gettext('Mercado Pago')
},
readyToSubmitApproval: false,
load: function () {
if (pretixpaypal.paypal === null) {
pretixpaypal.client_id = $.trim($("#paypal_client_id").html());
pretixpaypal.merchant_id = $.trim($("#paypal_merchant_id").html());
pretixpaypal.debug_buyer_country = $.trim($("#paypal_buyer_country").html());
pretixpaypal.continue_button = $('.checkout-button-row').closest("form").find(".checkout-button-row .btn-primary");
pretixpaypal.continue_button.closest('div').append('<div id="paypal-button-container"></div>');
pretixpaypal.additional_disabled_funding = $.trim($("#paypal_disable_funding").html());
pretixpaypal.additional_enabled_funding = $.trim($("#paypal_enable_funding").html());
pretixpaypal.paypage = Boolean($('#paypal-button-container').data('paypage'));
pretixpaypal.order_id = $.trim($("#paypal_oid").html());
pretixpaypal.currency = $("body").attr("data-currency");
pretixpaypal.locale = this.guessLocale();
}
load: function () {
if (pretixpaypal.paypal === null) {
pretixpaypal.client_id = $.trim($('#paypal_client_id').html())
pretixpaypal.merchant_id = $.trim($('#paypal_merchant_id').html())
pretixpaypal.debug_buyer_country = $.trim($('#paypal_buyer_country').html())
pretixpaypal.continue_button = $('.checkout-button-row').closest('form').find('.checkout-button-row .btn-primary')
pretixpaypal.continue_button.closest('div').append('<div id="paypal-button-container"></div>')
pretixpaypal.additional_disabled_funding = $.trim($('#paypal_disable_funding').html())
pretixpaypal.additional_enabled_funding = $.trim($('#paypal_enable_funding').html())
pretixpaypal.paypage = Boolean($('#paypal-button-container').data('paypage'))
pretixpaypal.order_id = $.trim($('#paypal_oid').html())
pretixpaypal.currency = $('body').attr('data-currency')
pretixpaypal.locale = this.guessLocale()
}
$("input[name=payment][value^='paypal']").change(function () {
if (pretixpaypal.paypal !== null) {
pretixpaypal.renderButton($(this).val());
} else {
pretixpaypal.continue_button.prop("disabled", true);
}
});
$('input[name=payment][value^=\'paypal\']').change(function () {
if (pretixpaypal.paypal !== null) {
pretixpaypal.renderButton($(this).val())
} else {
pretixpaypal.continue_button.prop('disabled', true)
}
})
$("input[name=payment]").not("[value^='paypal']").change(function () {
pretixpaypal.restore();
});
$('input[name=payment]').not('[value^=\'paypal\']').change(function () {
pretixpaypal.restore()
})
// If paypal is pre-selected, we must disable the continue button and handle it after SDK is loaded
if ($("input[name=payment][value^='paypal']").is(':checked')) {
pretixpaypal.continue_button.prop("disabled", true);
}
// If paypal is pre-selected, we must disable the continue button and handle it after SDK is loaded
if ($('input[name=payment][value^=\'paypal\']').is(':checked')) {
pretixpaypal.continue_button.prop('disabled', true)
}
// We are setting the cogwheel already here, as the renderAPM() method might take some time to get loaded.
const apmtextselector = $("input[name=payment][value=paypal_apm]").closest("label").find(".accordion-label-text");
apmtextselector.append(' <span aria-hidden="true" class="fa fa-cog fa-spin"></span>');
// We are setting the cogwheel already here, as the renderAPM() method might take some time to get loaded.
const apmtextselector = $('input[name=payment][value=paypal_apm]').closest('label').find('.accordion-label-text')
apmtextselector.append(' <span aria-hidden="true" class="fa fa-cog fa-spin"></span>')
let sdk_url = 'https://www.paypal.com/sdk/js' +
'?client-id=' + pretixpaypal.client_id +
'&components=buttons,funding-eligibility' +
'&currency=' + pretixpaypal.currency;
let sdk_url = 'https://www.paypal.com/sdk/js'
+ '?client-id=' + pretixpaypal.client_id
+ '&components=buttons,funding-eligibility'
+ '&currency=' + pretixpaypal.currency
if (pretixpaypal.locale) {
sdk_url += '&locale=' + pretixpaypal.locale;
}
if (pretixpaypal.locale) {
sdk_url += '&locale=' + pretixpaypal.locale
}
if (pretixpaypal.merchant_id) {
sdk_url += '&merchant-id=' + pretixpaypal.merchant_id;
}
if (pretixpaypal.merchant_id) {
sdk_url += '&merchant-id=' + pretixpaypal.merchant_id
}
if (pretixpaypal.additional_disabled_funding) {
sdk_url += '&disable-funding=' + [pretixpaypal.additional_disabled_funding].filter(Boolean).join(',');
}
if (pretixpaypal.additional_disabled_funding) {
sdk_url += '&disable-funding=' + [pretixpaypal.additional_disabled_funding].filter(Boolean).join(',')
}
if (pretixpaypal.additional_enabled_funding) {
sdk_url += '&enable-funding=' + [pretixpaypal.additional_enabled_funding].filter(Boolean).join(',');
}
if (pretixpaypal.additional_enabled_funding) {
sdk_url += '&enable-funding=' + [pretixpaypal.additional_enabled_funding].filter(Boolean).join(',')
}
if (pretixpaypal.debug_buyer_country) {
sdk_url += '&buyer-country=' + pretixpaypal.debug_buyer_country;
}
if (pretixpaypal.debug_buyer_country) {
sdk_url += '&buyer-country=' + pretixpaypal.debug_buyer_country
}
let ppscript = document.createElement('script');
let ready = false;
let head = document.getElementsByTagName("head")[0];
ppscript.setAttribute('src', sdk_url);
ppscript.setAttribute('data-csp-nonce', $.trim($("#csp_nonce").html()));
ppscript.setAttribute('data-page-type', 'checkout');
ppscript.setAttribute('data-partner-attribution-id', 'ramiioGmbH_Cart_PPCP');
document.head.appendChild(ppscript);
let ppscript = document.createElement('script')
let ready = false
let head = document.getElementsByTagName('head')[0]
ppscript.setAttribute('src', sdk_url)
ppscript.setAttribute('data-csp-nonce', $.trim($('#csp_nonce').html()))
ppscript.setAttribute('data-page-type', 'checkout')
ppscript.setAttribute('data-partner-attribution-id', 'ramiioGmbH_Cart_PPCP')
document.head.appendChild(ppscript)
ppscript.onload = ppscript.onreadystatechange = function () {
if (!ready && (!this.readyState || this.readyState === "loaded" || this.readyState === "complete")) {
ready = true;
ppscript.onload = ppscript.onreadystatechange = function () {
if (!ready && (!this.readyState || this.readyState === 'loaded' || this.readyState === 'complete')) {
ready = true
pretixpaypal.paypal = paypal;
pretixpaypal.paypal = paypal
// Handle memory leak in IE
ppscript.onload = ppscript.onreadystatechange = null;
if (head && ppscript.parentNode) {
head.removeChild(ppscript);
}
}
};
// Handle memory leak in IE
ppscript.onload = ppscript.onreadystatechange = null
if (head && ppscript.parentNode) {
head.removeChild(ppscript)
}
}
}
document.addEventListener("visibilitychange", this.onApproveSubmit);
},
document.addEventListener('visibilitychange', this.onApproveSubmit)
},
ready: function () {
if ($("input[name=payment][value=paypal_apm]").length > 0) {
pretixpaypal.renderAPMs();
}
ready: function () {
if ($('input[name=payment][value=paypal_apm]').length > 0) {
pretixpaypal.renderAPMs()
}
if ($("input[name=payment][value^='paypal']").is(':checked')) {
pretixpaypal.renderButton($("input[name=payment][value^='paypal']:checked").val());
} else if ($(".payment-redo-form").length) {
pretixpaypal.renderButton($("input[name=payment][value^='paypal']").val());
} else if ($('#paypal-button-container').data('paypage')) {
pretixpaypal.renderButton('paypal_apm');
}
},
if ($('input[name=payment][value^=\'paypal\']').is(':checked')) {
pretixpaypal.renderButton($('input[name=payment][value^=\'paypal\']:checked').val())
} else if ($('.payment-redo-form').length) {
pretixpaypal.renderButton($('input[name=payment][value^=\'paypal\']').val())
} else if ($('#paypal-button-container').data('paypage')) {
pretixpaypal.renderButton('paypal_apm')
}
},
restore: function () {
// if PayPal has not been initialized, there shouldn't be anything to cleanup
if (pretixpaypal.paypal !== null) {
$('#paypal-button-container').empty()
pretixpaypal.continue_button.text(gettext('Continue'));
pretixpaypal.continue_button.show();
}
pretixpaypal.continue_button.prop("disabled", false);
},
restore: function () {
// if PayPal has not been initialized, there shouldn't be anything to cleanup
if (pretixpaypal.paypal !== null) {
$('#paypal-button-container').empty()
pretixpaypal.continue_button.text(gettext('Continue'))
pretixpaypal.continue_button.show()
}
pretixpaypal.continue_button.prop('disabled', false)
},
renderButton: function (method) {
if (method === 'paypal') {
method = "wallet"
} else {
method = method.split('paypal_').at(-1)
}
pretixpaypal.method = pretixpaypal.method_map[method];
renderButton: function (method) {
if (method === 'paypal') {
method = 'wallet'
} else {
method = method.split('paypal_').at(-1)
}
pretixpaypal.method = pretixpaypal.method_map[method]
if (pretixpaypal.method.method === 'apm' && !pretixpaypal.paypage) {
pretixpaypal.restore();
return;
}
if (pretixpaypal.method.method === 'apm' && !pretixpaypal.paypage) {
pretixpaypal.restore()
return
}
$('#paypal-button-container').empty()
$('#paypal-card-container').empty()
$('#paypal-button-container').empty()
$('#paypal-card-container').empty()
let button = pretixpaypal.paypal.Buttons({
fundingSource: pretixpaypal.method.funding_source,
style: {
layout: pretixpaypal.method.early_auth ? 'horizontal' : 'vertical',
//color: 'white',
shape: 'rect',
label: 'pay',
tagline: false
},
createOrder: function (data, actions) {
if (pretixpaypal.order_id) {
return pretixpaypal.order_id;
}
let button = pretixpaypal.paypal.Buttons({
fundingSource: pretixpaypal.method.funding_source,
style: {
layout: pretixpaypal.method.early_auth ? 'horizontal' : 'vertical',
// color: 'white',
shape: 'rect',
label: 'pay',
tagline: false
},
createOrder: function (data, actions) {
if (pretixpaypal.order_id) {
return pretixpaypal.order_id
}
// On the paypal:pay view, we already pregenerated the OID.
// Since this view is also only used for APMs, we only need the XHR-calls for the Smart Payment Buttons.
if (pretixpaypal.paypage) {
return $("#payment_paypal_" + pretixpaypal.method.method + "_oid");
} else {
var xhrurl = $("#payment_paypal_" + pretixpaypal.method.method + "_xhr").val();
}
// On the paypal:pay view, we already pregenerated the OID.
// Since this view is also only used for APMs, we only need the XHR-calls for the Smart Payment Buttons.
if (pretixpaypal.paypage) {
return $('#payment_paypal_' + pretixpaypal.method.method + '_oid')
} else {
var xhrurl = $('#payment_paypal_' + pretixpaypal.method.method + '_xhr').val()
}
return fetch(xhrurl, {
method: 'POST'
}).then(function (res) {
return res.json();
}).then(function (data) {
if ('id' in data) {
return data.id;
} else {
// Refreshing the page to surface the request-error message
location.reload();
}
});
},
onApprove: function (data, actions) {
waitingDialog.show(gettext("Confirming your payment …"));
pretixpaypal.order_id = data.orderID;
pretixpaypal.payer_id = data.payerID;
return fetch(xhrurl, {
method: 'POST'
}).then(function (res) {
return res.json()
}).then(function (data) {
if ('id' in data) {
return data.id
} else {
// Refreshing the page to surface the request-error message
location.reload()
}
})
},
onApprove: function (data, actions) {
waitingDialog.show(gettext('Confirming your payment …'))
pretixpaypal.order_id = data.orderID
pretixpaypal.payer_id = data.payerID
let method = pretixpaypal.paypage ? "wallet" : pretixpaypal.method.method;
let selectorstub = "#payment_paypal_" + method;
// Insert the tokens into the form, so it gets submitted to the server
$(selectorstub + "_oid").val(pretixpaypal.order_id);
$(selectorstub + "_payer").val(pretixpaypal.payer_id);
let method = pretixpaypal.paypage ? 'wallet' : pretixpaypal.method.method
let selectorstub = '#payment_paypal_' + method
// Insert the tokens into the form, so it gets submitted to the server
$(selectorstub + '_oid').val(pretixpaypal.order_id)
$(selectorstub + '_payer').val(pretixpaypal.payer_id)
// We are moving the submission to a separate function, which is also an EventListener, since
// SFSafariView refuses to submit a form that is not visible. Unfortunately, that is exactly the case
// when the ticket shop is used on iOS within an SFSafariView and the PayPal payment popup has not
// closed itself quickly enough.
pretixpaypal.readyToSubmitApproval = true;
pretixpaypal.onApproveSubmit();
// We are moving the submission to a separate function, which is also an EventListener, since
// SFSafariView refuses to submit a form that is not visible. Unfortunately, that is exactly the case
// when the ticket shop is used on iOS within an SFSafariView and the PayPal payment popup has not
// closed itself quickly enough.
pretixpaypal.readyToSubmitApproval = true
pretixpaypal.onApproveSubmit()
// billingToken: null
// facilitatorAccessToken: "A21AAL_fEu0gDD-sIXyOy65a6MjgSJJrhmxuPcxxUGnL5gW2DzTxiiAksfoC4x8hD-BjeY1LsFVKl7ceuO7UR1a9pQr8Q_AVw"
// orderID: "7RF70259NY7589848"
// payerID: "8M3BU92Z97VXA"
// paymentID: null
},
});
// billingToken: null
// facilitatorAccessToken: "A21AAL_fEu0gDD-sIXyOy65a6MjgSJJrhmxuPcxxUGnL5gW2DzTxiiAksfoC4x8hD-BjeY1LsFVKl7ceuO7UR1a9pQr8Q_AVw"
// orderID: "7RF70259NY7589848"
// payerID: "8M3BU92Z97VXA"
// paymentID: null
},
})
if (button.isEligible()) {
button.render('#paypal-button-container');
pretixpaypal.continue_button.hide();
} else {
pretixpaypal.continue_button.text(gettext('Payment method unavailable'));
pretixpaypal.continue_button.show();
}
},
if (button.isEligible()) {
button.render('#paypal-button-container')
pretixpaypal.continue_button.hide()
} else {
pretixpaypal.continue_button.text(gettext('Payment method unavailable'))
pretixpaypal.continue_button.show()
}
},
onApproveSubmit: function() {
if (document.visibilityState === "visible" && pretixpaypal.readyToSubmitApproval === true) {
let method = pretixpaypal.paypage ? "wallet" : pretixpaypal.method.method;
let selectorstub = "#payment_paypal_" + method;
var $form = $(selectorstub + "_oid").closest("form");
onApproveSubmit: function () {
if (document.visibilityState === 'visible' && pretixpaypal.readyToSubmitApproval === true) {
let method = pretixpaypal.paypage ? 'wallet' : pretixpaypal.method.method
let selectorstub = '#payment_paypal_' + method
let $form = $(selectorstub + '_oid').closest('form')
$form.get(0).submit();
}
},
$form.get(0).submit()
}
},
renderAPMs: function () {
pretixpaypal.restore();
let inputselector = $("input[name=payment][value=paypal_apm]");
let textselector = inputselector.closest("label").find('.accordion-label-text');
let eligibles = [];
renderAPMs: function () {
pretixpaypal.restore()
let inputselector = $('input[name=payment][value=paypal_apm]')
let textselector = inputselector.closest('label').find('.accordion-label-text')
let eligibles = []
pretixpaypal.paypal.getFundingSources().forEach(function (fundingSource) {
// Let's always skip PayPal, since it's always a dedicated funding source
if (fundingSource === 'paypal') {
return;
}
pretixpaypal.paypal.getFundingSources().forEach(function (fundingSource) {
// Let's always skip PayPal, since it's always a dedicated funding source
if (fundingSource === 'paypal') {
return
}
// This could also be paypal.Marks() - but they only expose images instead of cleartext...
let button = pretixpaypal.paypal.Buttons({
fundingSource: fundingSource
});
// This could also be paypal.Marks() - but they only expose images instead of cleartext...
let button = pretixpaypal.paypal.Buttons({
fundingSource: fundingSource
})
if (button.isEligible()) {
eligibles.push(gettext(pretixpaypal.apm_map[fundingSource] || fundingSource));
}
});
if (button.isEligible()) {
eligibles.push(gettext(pretixpaypal.apm_map[fundingSource] || fundingSource))
}
})
inputselector.attr('title', eligibles.join(', '));
textselector.fadeOut(300, function () {
textselector.text(eligibles.join(', '));
textselector.fadeIn(300);
});
},
inputselector.attr('title', eligibles.join(', '))
textselector.fadeOut(300, function () {
textselector.text(eligibles.join(', '))
textselector.fadeIn(300)
})
},
guessLocale: function() {
// This is a horrible hackjob and does not at all take into consideration the actual locale.
// Instead, we only look at the language that the shop is currently being displayed in and make
// that into a locale.
let allowed_locales = [
'en_US',
'ar_DZ',
'fr_FR',
'es_ES',
'zh_CN',
'de_DE',
'nl_NL',
'pt_PT',
'cs_CZ',
'da_DK',
'fi_FI',
'el_GR',
'hu_HU',
'id_ID',
'he_IL',
'it_IT',
'ja_JP',
'ru_RU',
'no_NO',
'pl_PL',
'sk_SK',
'sv_SE',
'th_TH',
'tr_TR',
]
let lang = $("body").attr("data-locale").split('-')[0];
return allowed_locales.find(element => element.startsWith(lang));
}
};
guessLocale: function () {
// This is a horrible hackjob and does not at all take into consideration the actual locale.
// Instead, we only look at the language that the shop is currently being displayed in and make
// that into a locale.
let allowed_locales = [
'en_US',
'ar_DZ',
'fr_FR',
'es_ES',
'zh_CN',
'de_DE',
'nl_NL',
'pt_PT',
'cs_CZ',
'da_DK',
'fi_FI',
'el_GR',
'hu_HU',
'id_ID',
'he_IL',
'it_IT',
'ja_JP',
'ru_RU',
'no_NO',
'pl_PL',
'sk_SK',
'sv_SE',
'th_TH',
'tr_TR',
]
let lang = $('body').attr('data-locale').split('-')[0]
return allowed_locales.find(element => element.startsWith(lang))
}
}
$(function () {
// This script is always loaded if paypal is enabled as a payment method, regardless of
// whether it is available (it could e.g. be hidden or limited to certain countries).
// We do not want to unnecessarily load the sdk.
// If no paypal/paypal_apm payment option is present and we are not on
// the (APM) PayView, then we do not need the SDK.
if (!$("input[name=payment][value^='paypal']").length && !$('#paypal-button-container').data('paypage')) {
return
}
// This script is always loaded if paypal is enabled as a payment method, regardless of
// whether it is available (it could e.g. be hidden or limited to certain countries).
// We do not want to unnecessarily load the sdk.
// If no paypal/paypal_apm payment option is present and we are not on
// the (APM) PayView, then we do not need the SDK.
if (!$('input[name=payment][value^=\'paypal\']').length && !$('#paypal-button-container').data('paypage')) {
return
}
pretixpaypal.load();
pretixpaypal.load();
(async() => {
while(!pretixpaypal.paypal)
await new Promise(resolve => setTimeout(resolve, 1000));
pretixpaypal.ready();
})();
});
(async () => {
while (!pretixpaypal.paypal)
await new Promise(resolve => setTimeout(resolve, 1000))
pretixpaypal.ready()
})()
})
+3 -3
View File
@@ -67,10 +67,10 @@ from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal2.client.customer.partners_merchantintegrations_get_request import (
PartnersMerchantIntegrationsGetRequest,
)
from pretix.plugins.paypal2.models import ReferencedPayPalObject
from pretix.plugins.paypal2.payment import (
PaypalMethod, PaypalMethod as Paypal, PaypalWallet,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session
@@ -350,8 +350,8 @@ def webhook(request, *args, **kwargs):
return HttpResponse("Invalid body, no event_type given", status=400)
if event_json['event_type'].startswith('PAYMENT.SALE.'):
from pretix.plugins.paypal.views import webhook
return webhook(request, *args, **kwargs)
logger.info(f"Received PPv1 webhook: {json.dumps(event_json)}")
return HttpResponse("PayPal V1 no longer supported", status=400)
# V1/V2 Sorting -- End
# We do not check the signature, we just use it as a trigger to look the charge up.
+15 -3
View File
@@ -55,6 +55,8 @@ from django_countries.fields import Country
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.units import mm
from reportlab.lib.utils import simpleSplit
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import PageBreak, Spacer, Table, TableStyle
@@ -226,10 +228,20 @@ class ReportlabExportMixin:
def page_header(self, canvas, doc):
from reportlab.lib.units import mm
canvas.setFont('OpenSans', 10)
canvas.drawString(doc.leftMargin, self.pagesize[1] - 15 * mm, self.get_left_header_string())
font_name = 'OpenSans'
font_size = 10
left_string = self.get_left_header_string()
right_string = self.get_right_header_string()
right_width = stringWidth(right_string, font_name, font_size)
max_left_width = self.pagesize[0] - doc.leftMargin - doc.rightMargin - right_width - 5 * mm
left_string_lines = simpleSplit(left_string, font_name, font_size, max_left_width)
if len(left_string_lines) > 1:
left_string = left_string_lines[0] + " …"
canvas.setFont(font_name, font_size)
canvas.drawString(doc.leftMargin, self.pagesize[1] - 15 * mm, left_string)
canvas.drawRightString(self.pagesize[0] - doc.rightMargin, self.pagesize[1] - 15 * mm,
self.get_right_header_string())
right_string)
canvas.setStrokeColorRGB(0, 0, 0)
canvas.line(doc.leftMargin, self.pagesize[1] - 17 * mm,
self.pagesize[0] - doc.rightMargin, self.pagesize[1] - 17 * mm)
+11 -8
View File
@@ -56,18 +56,11 @@ from pretix.base.services.placeholders import FormPlaceholderMixin # noqa
class BaseMailForm(FormPlaceholderMixin, forms.Form):
subject = forms.CharField(label=_("Subject"))
message = forms.CharField(label=_("Message"))
attachment = CachedFileField(
label=_("Attachment"),
required=False,
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT,
help_text=_('Sending an attachment increases the chance of your email not arriving or being sorted into spam folders. We recommend only using PDFs '
'of no more than 2 MB in size.'),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT
)
def __init__(self, *args, **kwargs):
event = self.event = kwargs.pop('event')
context_parameters = kwargs.pop('context_parameters')
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['subject'] = I18nFormField(
label=_('Subject'),
@@ -79,6 +72,16 @@ class BaseMailForm(FormPlaceholderMixin, forms.Form):
widget=I18nMarkdownTextarea, required=True,
locales=event.settings.get('locales'),
)
self.fields['attachment'] = CachedFileField(
label=_("Attachment"),
required=False,
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT,
help_text=_(
'Sending an attachment increases the chance of your email not arriving or being sorted into spam folders. We recommend only using PDFs '
'of no more than 2 MB in size.'),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT,
request=request,
)
self._set_field_placeholders('subject', context_parameters, rich=False)
self._set_field_placeholders('message', context_parameters, rich=True)
+26 -18
View File
@@ -157,6 +157,7 @@ class BaseSenderView(EventPermissionRequiredMixin, FormView):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['context_parameters'] = self.context_parameters
kwargs['request'] = self.request
if 'from_log' in self.request.GET:
try:
from_log_id = self.request.GET.get('from_log')
@@ -354,9 +355,9 @@ class OrderSendView(BaseSenderView):
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=True)
orders = qs.filter(statusq)
opq = OrderPosition.objects.filter(
opq = OrderPosition.objects.with_scopes_disabled().filter(
Q(item_id__in=[i.pk for i in form.cleaned_data.get('items')]) | Q(Exists(
OrderPosition.objects.filter(
OrderPosition.objects.with_scopes_disabled().filter(
addon_to_id=OuterRef('pk'),
item_id__in=[i.pk for i in form.cleaned_data.get('items')]
)
@@ -366,36 +367,43 @@ class OrderSendView(BaseSenderView):
)
if form.cleaned_data.get('filter_checkins'):
ql = []
ci_filter = Q(pk__in=[]) # return nothing
if form.cleaned_data.get('not_checked_in'):
consider_tickets_used_lists = list(self.request.event.checkin_lists.filter(consider_tickets_used=True).values_list("id", flat=True))
opq = opq.alias(
any_checkins=Exists(
Checkin.all.filter(
Q(position_id=OuterRef('pk')) | Q(position__addon_to_id=OuterRef('pk')),
successful=True,
list__consider_tickets_used=True,
Checkin.objects.with_scopes_disabled().filter(
position_id=OuterRef('pk'),
list_id__in=consider_tickets_used_lists,
)
) | Exists(
Checkin.objects.with_scopes_disabled().filter(
position__addon_to_id=OuterRef('pk'),
list_id__in=consider_tickets_used_lists,
)
)
)
ql.append(Q(any_checkins=False))
ci_filter |= Q(any_checkins=False)
if form.cleaned_data.get('checkin_lists'):
opq = opq.alias(
matching_checkins=Exists(
Checkin.all.filter(
Q(position_id=OuterRef('pk')) | Q(position__addon_to_id=OuterRef('pk')),
Checkin.objects.with_scopes_disabled().filter(
position_id=OuterRef('pk'),
list_id__in=[i.pk for i in form.cleaned_data.get('checkin_lists', [])],
)
) | Exists(
Checkin.objects.with_scopes_disabled().filter(
position__addon_to_id=OuterRef('pk'),
list_id__in=[i.pk for i in form.cleaned_data.get('checkin_lists', [])],
successful=True
)
)
)
ql.append(Q(matching_checkins=True))
if len(ql) == 2:
opq = opq.filter(ql[0] | ql[1])
elif ql:
opq = opq.filter(ql[0])
else:
opq = opq.none()
ci_filter |= Q(matching_checkins=True)
opq = opq.filter(ci_filter)
if form.cleaned_data.get('subevent'):
opq = opq.filter(subevent=form.cleaned_data.get('subevent'))
@@ -1,81 +1,81 @@
/*globals $, Morris, gettext, django*/
function gettext(msgid) {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid);
}
return msgid;
/* globals Morris, django */
function gettext (msgid) {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid)
}
return msgid
}
$(function () {
$(".chart").css("height", "250px");
new Morris.Area({
element: 'obd_chart',
data: JSON.parse($("#obd-data").html()),
xkey: 'date',
ykeys: ['ordered', 'paid'],
labels: [gettext('Placed orders'), gettext('Paid orders')],
lineColors: ['#3b1c4a', '#50a167'],
smooth: false,
resize: true,
fillOpacity: 0.3,
behaveLikeLine: true
});
new Morris.Area({
element: 'abd_chart',
data: JSON.parse($("#abd-data").html()),
xkey: 'date',
ykeys: ['ordered', 'paid'],
labels: [gettext('Attendees (ordered)'), gettext('Attendees (paid)')],
lineColors: ['#3b1c4a', '#50a167'],
smooth: false,
resize: true,
fillOpacity: 0.3,
behaveLikeLine: true
});
new Morris.Area({
element: 'abt_chart',
data: JSON.parse($("#abt-data").html()),
xkey: 'date',
ykeys: ['ordered', 'paid'],
labels: [gettext('Attendees (ordered)'), gettext('Attendees (paid)')],
lineColors: ['#3b1c4a', '#50a167'],
smooth: false,
resize: true,
fillOpacity: 0.3,
behaveLikeLine: true
});
new Morris.Area({
element: 'rev_chart',
data: JSON.parse($("#rev-data").html()),
xkey: 'date',
ykeys: ['revenue'],
labels: [gettext('Total revenue')],
smooth: false,
resize: true,
lineColors: ['#3b1c4a'],
fillOpacity: 0.3,
preUnits: $.trim($("#currency").html()) + ' '
});
new Morris.Bar({
element: 'obp_chart',
data: JSON.parse($("#obp-data").html()),
xkey: 'item_short',
ykeys: ['ordered', 'paid'],
labels: [gettext('Placed orders'), gettext('Paid orders')],
barColors: ['#3b1c4a', '#50a167'],
hoverCallback: function (index, options, content, row) {
console.log(content);
var $c = $("<div>" + content + "</div>");
var $label = $c.find(".morris-hover-row-label");
$label.text(row.item);
var newc = $label.get(0).outerHTML;
$c.find('.morris-hover-point').each(function (i, r) {
if ($.trim($(r).text().split("\n")[2]) !== "0") {
newc += r.outerHTML;
}
});
return newc;
},
resize: true,
xLabelAngle: 30
});
});
$('.chart').css('height', '250px')
new Morris.Area({
element: 'obd_chart',
data: JSON.parse($('#obd-data').html()),
xkey: 'date',
ykeys: ['ordered', 'paid'],
labels: [gettext('Placed orders'), gettext('Paid orders')],
lineColors: ['#3b1c4a', '#50a167'],
smooth: false,
resize: true,
fillOpacity: 0.3,
behaveLikeLine: true
})
new Morris.Area({
element: 'abd_chart',
data: JSON.parse($('#abd-data').html()),
xkey: 'date',
ykeys: ['ordered', 'paid'],
labels: [gettext('Attendees (ordered)'), gettext('Attendees (paid)')],
lineColors: ['#3b1c4a', '#50a167'],
smooth: false,
resize: true,
fillOpacity: 0.3,
behaveLikeLine: true
})
new Morris.Area({
element: 'abt_chart',
data: JSON.parse($('#abt-data').html()),
xkey: 'date',
ykeys: ['ordered', 'paid'],
labels: [gettext('Attendees (ordered)'), gettext('Attendees (paid)')],
lineColors: ['#3b1c4a', '#50a167'],
smooth: false,
resize: true,
fillOpacity: 0.3,
behaveLikeLine: true
})
new Morris.Area({
element: 'rev_chart',
data: JSON.parse($('#rev-data').html()),
xkey: 'date',
ykeys: ['revenue'],
labels: [gettext('Total revenue')],
smooth: false,
resize: true,
lineColors: ['#3b1c4a'],
fillOpacity: 0.3,
preUnits: $.trim($('#currency').html()) + ' '
})
new Morris.Bar({
element: 'obp_chart',
data: JSON.parse($('#obp-data').html()),
xkey: 'item_short',
ykeys: ['ordered', 'paid'],
labels: [gettext('Placed orders'), gettext('Paid orders')],
barColors: ['#3b1c4a', '#50a167'],
hoverCallback: function (index, options, content, row) {
console.log(content)
let $c = $('<div>' + content + '</div>')
let $label = $c.find('.morris-hover-row-label')
$label.text(row.item)
let newc = $label.get(0).outerHTML
$c.find('.morris-hover-point').each(function (i, r) {
if ($.trim($(r).text().split('\n')[2]) !== '0') {
newc += r.outerHTML
}
})
return newc
},
resize: true,
xLabelAngle: 30
})
})
@@ -1,435 +1,435 @@
/*global $, stripe_pubkey, stripe_loadingmessage, gettext */
'use strict';
/* global stripe_pubkey, stripe_loadingmessage, gettext */
'use strict'
var pretixstripe = {
stripe: null,
elements: null,
card: null,
sepa: null,
affirm: null,
klarna: null,
paymentRequest: null,
paymentRequestButton: null,
stripe: null,
elements: null,
card: null,
sepa: null,
affirm: null,
klarna: null,
paymentRequest: null,
paymentRequestButton: null,
'pm_request': function (method, element, kwargs = {}) {
waitingDialog.show(gettext("Contacting Stripe …"));
$(".stripe-errors").hide();
pm_request: function (method, element, kwargs = {}) {
waitingDialog.show(gettext('Contacting Stripe …'))
$('.stripe-errors').hide()
pretixstripe.stripe.createPaymentMethod(method, element, kwargs).then(function (result) {
waitingDialog.hide();
if (result.error) {
$(".stripe-errors").stop().hide().removeClass("sr-only");
$(".stripe-errors").html("<div class='alert alert-danger'>" + result.error.message + "</div>");
$(".stripe-errors").slideDown();
} else {
var $form = $("#stripe_" + method + "_payment_method_id").closest("form");
// Insert the token into the form so it gets submitted to the server
$("#stripe_" + method + "_payment_method_id").val(result.paymentMethod.id);
if (method === 'card') {
$("#stripe_card_brand").val(result.paymentMethod.card.brand);
$("#stripe_card_last4").val(result.paymentMethod.card.last4);
}
if (method === 'sepa_debit') {
$("#stripe_sepa_debit_last4").val(result.paymentMethod.sepa_debit.last4);
}
// and submit
$form.get(0).submit();
}
}).catch((e) => {
waitingDialog.hide();
$(".stripe-errors").stop().hide().removeClass("sr-only");
$(".stripe-errors").html("<div class='alert alert-danger'>Technical error, please contact support: " + e + "</div>");
$(".stripe-errors").slideDown();
});
},
'load': function () {
if (pretixstripe.stripe !== null) {
return;
}
$('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", true);
$.ajax(
{
url: 'https://js.stripe.com/v3/',
dataType: 'script',
success: function () {
if ($.trim($("#stripe_connectedAccountId").html())) {
pretixstripe.stripe = Stripe($.trim($("#stripe_pubkey").html()), {
stripeAccount: $.trim($("#stripe_connectedAccountId").html()),
locale: $.trim($("body").attr("data-locale"))
});
} else {
pretixstripe.stripe = Stripe($.trim($("#stripe_pubkey").html()), {
locale: $.trim($("body").attr("data-locale"))
});
}
pretixstripe.elements = pretixstripe.stripe.elements();
if ($.trim($("#stripe_merchantcountry").html()) !== "") {
try {
pretixstripe.paymentRequest = pretixstripe.stripe.paymentRequest({
country: $("#stripe_merchantcountry").html(),
currency: $("#stripe_card_currency").val().toLowerCase(),
total: {
label: gettext('Total'),
amount: parseInt($("#stripe_card_total").val())
},
displayItems: [],
requestPayerName: false,
requestPayerEmail: false,
requestPayerPhone: false,
requestShipping: false,
});
pretixstripe.stripe.createPaymentMethod(method, element, kwargs).then(function (result) {
waitingDialog.hide()
if (result.error) {
$('.stripe-errors').stop().hide().removeClass('sr-only')
$('.stripe-errors').html('<div class=\'alert alert-danger\'>' + result.error.message + '</div>')
$('.stripe-errors').slideDown()
} else {
let $form = $('#stripe_' + method + '_payment_method_id').closest('form')
// Insert the token into the form so it gets submitted to the server
$('#stripe_' + method + '_payment_method_id').val(result.paymentMethod.id)
if (method === 'card') {
$('#stripe_card_brand').val(result.paymentMethod.card.brand)
$('#stripe_card_last4').val(result.paymentMethod.card.last4)
}
if (method === 'sepa_debit') {
$('#stripe_sepa_debit_last4').val(result.paymentMethod.sepa_debit.last4)
}
// and submit
$form.get(0).submit()
}
}).catch((e) => {
waitingDialog.hide()
$('.stripe-errors').stop().hide().removeClass('sr-only')
$('.stripe-errors').html('<div class=\'alert alert-danger\'>Technical error, please contact support: ' + e + '</div>')
$('.stripe-errors').slideDown()
})
},
load: function () {
if (pretixstripe.stripe !== null) {
return
}
$('.stripe-container').closest('form').find('.checkout-button-row .btn-primary').prop('disabled', true)
$.ajax(
{
url: 'https://js.stripe.com/v3/',
dataType: 'script',
success: function () {
if ($.trim($('#stripe_connectedAccountId').html())) {
pretixstripe.stripe = Stripe($.trim($('#stripe_pubkey').html()), {
stripeAccount: $.trim($('#stripe_connectedAccountId').html()),
locale: $.trim($('body').attr('data-locale'))
})
} else {
pretixstripe.stripe = Stripe($.trim($('#stripe_pubkey').html()), {
locale: $.trim($('body').attr('data-locale'))
})
}
pretixstripe.elements = pretixstripe.stripe.elements()
if ($.trim($('#stripe_merchantcountry').html()) !== '') {
try {
pretixstripe.paymentRequest = pretixstripe.stripe.paymentRequest({
country: $('#stripe_merchantcountry').html(),
currency: $('#stripe_card_currency').val().toLowerCase(),
total: {
label: gettext('Total'),
amount: parseInt($('#stripe_card_total').val())
},
displayItems: [],
requestPayerName: false,
requestPayerEmail: false,
requestPayerPhone: false,
requestShipping: false,
})
pretixstripe.paymentRequest.on('paymentmethod', function (ev) {
ev.complete('success');
pretixstripe.paymentRequest.on('paymentmethod', function (ev) {
ev.complete('success')
var $form = $("#stripe_card_payment_method_id").closest("form");
// Insert the token into the form so it gets submitted to the server
$("#stripe_card_payment_method_id").val(ev.paymentMethod.id);
$("#stripe_card_brand").val(ev.paymentMethod.card.brand);
$("#stripe_card_last4").val(ev.paymentMethod.card.last4);
// and submit
$form.get(0).submit();
});
} catch (e) {
pretixstripe.paymentRequest = null;
}
} else {
pretixstripe.paymentRequest = null;
}
if ($("#stripe-card").length) {
pretixstripe.card = pretixstripe.elements.create('card', {
'style': {
'base': {
'fontFamily': '"Open Sans","OpenSans","Helvetica Neue",Helvetica,Arial,sans-serif',
'fontSize': '14px',
'color': '#555555',
'lineHeight': '1.42857',
'border': '1px solid #ccc',
'::placeholder': {
color: 'rgba(0,0,0,0.4)',
},
},
'invalid': {
'color': 'red',
},
},
classes: {
focus: 'is-focused',
invalid: 'has-error',
}
});
pretixstripe.card.mount("#stripe-card");
pretixstripe.card.on('ready', function () {
$('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false);
});
}
if ($("#stripe-sepa").length) {
pretixstripe.sepa = pretixstripe.elements.create('iban', {
'style': {
'base': {
'fontFamily': '"Open Sans","OpenSans","Helvetica Neue",Helvetica,Arial,sans-serif',
'fontSize': '14px',
'color': '#555555',
'lineHeight': '1.42857',
'border': '1px solid #ccc',
'::placeholder': {
color: 'rgba(0,0,0,0.4)',
},
},
'invalid': {
'color': 'red',
},
},
supportedCountries: ['SEPA'],
classes: {
focus: 'is-focused',
invalid: 'has-error',
}
});
pretixstripe.sepa.on('change', function (event) {
// List of IBAN-countries, that require the country as well as line1-property according to
// https://stripe.com/docs/payments/sepa-debit/accept-a-payment?platform=web&ui=element#web-submit-payment
if (['AD', 'PF', 'TF', 'GI', 'GB', 'GG', 'VA', 'IM', 'JE', 'MC', 'NC', 'BL', 'PM', 'SM', 'CH', 'WF'].indexOf(event.country) > 0) {
$("#stripe_sepa_debit_country").prop('checked', true);
$("#stripe_sepa_debit_country").change();
} else {
$("#stripe_sepa_debit_country").prop('checked', false);
$("#stripe_sepa_debit_country").change();
}
if (event.bankName) {
$("#stripe_sepa_debit_bank").val(event.bankName);
}
});
pretixstripe.sepa.mount("#stripe-sepa");
pretixstripe.sepa.on('ready', function () {
$('.stripe-container').closest("form").find(".checkout-button-row .btn-primary").prop("disabled", false);
});
}
if ($("#stripe-affirm").length) {
pretixstripe.affirm = pretixstripe.elements.create('affirmMessage', {
'amount': parseInt($("#stripe_affirm_total").val()),
'currency': $("#stripe_affirm_currency").val(),
});
let $form = $('#stripe_card_payment_method_id').closest('form')
// Insert the token into the form so it gets submitted to the server
$('#stripe_card_payment_method_id').val(ev.paymentMethod.id)
$('#stripe_card_brand').val(ev.paymentMethod.card.brand)
$('#stripe_card_last4').val(ev.paymentMethod.card.last4)
// and submit
$form.get(0).submit()
})
} catch (e) {
pretixstripe.paymentRequest = null
}
} else {
pretixstripe.paymentRequest = null
}
if ($('#stripe-card').length) {
pretixstripe.card = pretixstripe.elements.create('card', {
style: {
base: {
fontFamily: '"Open Sans","OpenSans","Helvetica Neue",Helvetica,Arial,sans-serif',
fontSize: '14px',
color: '#555555',
lineHeight: '1.42857',
border: '1px solid #ccc',
'::placeholder': {
color: 'rgba(0,0,0,0.4)',
},
},
invalid: {
color: 'red',
},
},
classes: {
focus: 'is-focused',
invalid: 'has-error',
}
})
pretixstripe.card.mount('#stripe-card')
pretixstripe.card.on('ready', function () {
$('.stripe-container').closest('form').find('.checkout-button-row .btn-primary').prop('disabled', false)
})
}
if ($('#stripe-sepa').length) {
pretixstripe.sepa = pretixstripe.elements.create('iban', {
style: {
base: {
fontFamily: '"Open Sans","OpenSans","Helvetica Neue",Helvetica,Arial,sans-serif',
fontSize: '14px',
color: '#555555',
lineHeight: '1.42857',
border: '1px solid #ccc',
'::placeholder': {
color: 'rgba(0,0,0,0.4)',
},
},
invalid: {
color: 'red',
},
},
supportedCountries: ['SEPA'],
classes: {
focus: 'is-focused',
invalid: 'has-error',
}
})
pretixstripe.sepa.on('change', function (event) {
// List of IBAN-countries, that require the country as well as line1-property according to
// https://stripe.com/docs/payments/sepa-debit/accept-a-payment?platform=web&ui=element#web-submit-payment
if (['AD', 'PF', 'TF', 'GI', 'GB', 'GG', 'VA', 'IM', 'JE', 'MC', 'NC', 'BL', 'PM', 'SM', 'CH', 'WF'].indexOf(event.country) > 0) {
$('#stripe_sepa_debit_country').prop('checked', true)
$('#stripe_sepa_debit_country').change()
} else {
$('#stripe_sepa_debit_country').prop('checked', false)
$('#stripe_sepa_debit_country').change()
}
if (event.bankName) {
$('#stripe_sepa_debit_bank').val(event.bankName)
}
})
pretixstripe.sepa.mount('#stripe-sepa')
pretixstripe.sepa.on('ready', function () {
$('.stripe-container').closest('form').find('.checkout-button-row .btn-primary').prop('disabled', false)
})
}
if ($('#stripe-affirm').length) {
pretixstripe.affirm = pretixstripe.elements.create('affirmMessage', {
amount: parseInt($('#stripe_affirm_total').val()),
currency: $('#stripe_affirm_currency').val(),
})
pretixstripe.affirm.mount('#stripe-affirm');
}
if ($("#stripe-klarna").length) {
try {
pretixstripe.klarna = pretixstripe.elements.create('paymentMethodMessaging', {
'amount': parseInt($("#stripe_klarna_total").val()),
'currency': $("#stripe_klarna_currency").val(),
'countryCode': $("#stripe_klarna_country").val(),
'paymentMethodTypes': ['klarna'],
});
pretixstripe.affirm.mount('#stripe-affirm')
}
if ($('#stripe-klarna').length) {
try {
pretixstripe.klarna = pretixstripe.elements.create('paymentMethodMessaging', {
amount: parseInt($('#stripe_klarna_total').val()),
currency: $('#stripe_klarna_currency').val(),
countryCode: $('#stripe_klarna_country').val(),
paymentMethodTypes: ['klarna'],
})
pretixstripe.klarna.mount('#stripe-klarna');
} catch (e) {
console.error(e);
$("#stripe-klarna").html("<div class='alert alert-danger'>Technical error, please contact support: " + e + "</div>");
}
}
if ($("#stripe-payment-request-button").length && pretixstripe.paymentRequest != null) {
pretixstripe.paymentRequestButton = pretixstripe.elements.create('paymentRequestButton', {
paymentRequest: pretixstripe.paymentRequest,
});
pretixstripe.klarna.mount('#stripe-klarna')
} catch (e) {
console.error(e)
$('#stripe-klarna').html('<div class=\'alert alert-danger\'>Technical error, please contact support: ' + e + '</div>')
}
}
if ($('#stripe-payment-request-button').length && pretixstripe.paymentRequest != null) {
pretixstripe.paymentRequestButton = pretixstripe.elements.create('paymentRequestButton', {
paymentRequest: pretixstripe.paymentRequest,
})
pretixstripe.paymentRequest.canMakePayment().then(function (result) {
if (result) {
pretixstripe.paymentRequestButton.mount('#stripe-payment-request-button');
$('#stripe-card-elements .stripe-or').removeClass("hidden");
$('#stripe-payment-request-button').parent().removeClass("hidden");
} else {
$('#stripe-payment-request-button').hide();
document.getElementById('stripe-payment-request-button').style.display = 'none';
}
});
}
}
}
);
},
'withStripe': function (callback) {
$.ajax({
url: 'https://js.stripe.com/v3/',
dataType: 'script',
success: function () {
if ($.trim($("#stripe_connectedAccountId").html())) {
pretixstripe.stripe = Stripe($.trim($("#stripe_pubkey").html()), {
stripeAccount: $.trim($("#stripe_connectedAccountId").html()),
locale: $.trim($("body").attr("data-locale"))
});
} else {
pretixstripe.stripe = Stripe($.trim($("#stripe_pubkey").html()), {
locale: $.trim($("body").attr("data-locale"))
});
}
callback();
}
});
},
'handleAlipayAction': function (payment_intent_client_secret) {
pretixstripe.withStripe(function () {
pretixstripe.stripe.confirmAlipayPayment(
payment_intent_client_secret,
{
return_url: window.location.href
}
).then(function (result) {
if (result.error) {
waitingDialog.hide();
$(".stripe-errors").stop().hide().removeClass("sr-only");
$(".stripe-errors").html("<div class='alert alert-danger'>Technical error, please contact support: " + result.error.message + "</div>");
$(".stripe-errors").slideDown();
} else {
waitingDialog.show(gettext("Confirming your payment …"));
}
});
});
},
'handleWechatAction': function (payment_intent_client_secret) {
pretixstripe.withStripe(function () {
pretixstripe.stripe.confirmWechatPayPayment(
payment_intent_client_secret,
{
payment_method_options: {
wechat_pay: {
client: 'web',
},
},
}
).then(function (result) {
if (result.error) {
waitingDialog.hide();
$(".stripe-errors").stop().hide().removeClass("sr-only");
$(".stripe-errors").html("<div class='alert alert-danger'>Technical error, please contact support: " + result.error.message + "</div>");
$(".stripe-errors").slideDown();
} else {
waitingDialog.show(gettext("Confirming your payment …"));
location.reload();
}
});
});
},
'handleCardAction': function (payment_intent_client_secret) {
pretixstripe.withStripe(function () {
pretixstripe.stripe.handleCardAction(
payment_intent_client_secret
).then(function (result) {
if (result.error) {
waitingDialog.hide();
$(".stripe-errors").stop().hide().removeClass("sr-only");
$(".stripe-errors").html("<div class='alert alert-danger'>Technical error, please contact support: " + result.error.message + "</div>");
$(".stripe-errors").slideDown();
} else {
waitingDialog.show(gettext("Confirming your payment …"));
location.reload();
}
});
});
},
'handlePaymentRedirectAction': function (payment_intent_next_action_redirect_url) {
waitingDialog.show(gettext("Contacting your bank …"));
pretixstripe.paymentRequest.canMakePayment().then(function (result) {
if (result) {
pretixstripe.paymentRequestButton.mount('#stripe-payment-request-button')
$('#stripe-card-elements .stripe-or').removeClass('hidden')
$('#stripe-payment-request-button').parent().removeClass('hidden')
} else {
$('#stripe-payment-request-button').hide()
document.getElementById('stripe-payment-request-button').style.display = 'none'
}
})
}
}
}
)
},
withStripe: function (callback) {
$.ajax({
url: 'https://js.stripe.com/v3/',
dataType: 'script',
success: function () {
if ($.trim($('#stripe_connectedAccountId').html())) {
pretixstripe.stripe = Stripe($.trim($('#stripe_pubkey').html()), {
stripeAccount: $.trim($('#stripe_connectedAccountId').html()),
locale: $.trim($('body').attr('data-locale'))
})
} else {
pretixstripe.stripe = Stripe($.trim($('#stripe_pubkey').html()), {
locale: $.trim($('body').attr('data-locale'))
})
}
callback()
}
})
},
handleAlipayAction: function (payment_intent_client_secret) {
pretixstripe.withStripe(function () {
pretixstripe.stripe.confirmAlipayPayment(
payment_intent_client_secret,
{
return_url: window.location.href
}
).then(function (result) {
if (result.error) {
waitingDialog.hide()
$('.stripe-errors').stop().hide().removeClass('sr-only')
$('.stripe-errors').html('<div class=\'alert alert-danger\'>Technical error, please contact support: ' + result.error.message + '</div>')
$('.stripe-errors').slideDown()
} else {
waitingDialog.show(gettext('Confirming your payment …'))
}
})
})
},
handleWechatAction: function (payment_intent_client_secret) {
pretixstripe.withStripe(function () {
pretixstripe.stripe.confirmWechatPayPayment(
payment_intent_client_secret,
{
payment_method_options: {
wechat_pay: {
client: 'web',
},
},
}
).then(function (result) {
if (result.error) {
waitingDialog.hide()
$('.stripe-errors').stop().hide().removeClass('sr-only')
$('.stripe-errors').html('<div class=\'alert alert-danger\'>Technical error, please contact support: ' + result.error.message + '</div>')
$('.stripe-errors').slideDown()
} else {
waitingDialog.show(gettext('Confirming your payment …'))
location.reload()
}
})
})
},
handleCardAction: function (payment_intent_client_secret) {
pretixstripe.withStripe(function () {
pretixstripe.stripe.handleCardAction(
payment_intent_client_secret
).then(function (result) {
if (result.error) {
waitingDialog.hide()
$('.stripe-errors').stop().hide().removeClass('sr-only')
$('.stripe-errors').html('<div class=\'alert alert-danger\'>Technical error, please contact support: ' + result.error.message + '</div>')
$('.stripe-errors').slideDown()
} else {
waitingDialog.show(gettext('Confirming your payment …'))
location.reload()
}
})
})
},
handlePaymentRedirectAction: function (payment_intent_next_action_redirect_url) {
waitingDialog.show(gettext('Contacting your bank …'))
let payment_intent_redirect_action_handling = $.trim($("#stripe_payment_intent_redirect_action_handling").html());
if (payment_intent_redirect_action_handling === 'iframe') {
let iframe = document.createElement('iframe');
iframe.src = payment_intent_next_action_redirect_url;
iframe.className = 'embed-responsive-item';
$('#scacontainer').append(iframe);
$('#scacontainer iframe').on("load", function () {
waitingDialog.hide();
});
} else if (payment_intent_redirect_action_handling === 'redirect') {
window.location.href = payment_intent_next_action_redirect_url;
}
}
};
let payment_intent_redirect_action_handling = $.trim($('#stripe_payment_intent_redirect_action_handling').html())
if (payment_intent_redirect_action_handling === 'iframe') {
let iframe = document.createElement('iframe')
iframe.src = payment_intent_next_action_redirect_url
iframe.className = 'embed-responsive-item'
$('#scacontainer').append(iframe)
$('#scacontainer iframe').on('load', function () {
waitingDialog.hide()
})
} else if (payment_intent_redirect_action_handling === 'redirect') {
window.location.href = payment_intent_next_action_redirect_url
}
}
}
$(function () {
if ($("#stripe_payment_intent_SCA_status").length) {
let payment_intent_redirect_action_handling = $.trim($("#stripe_payment_intent_redirect_action_handling").html());
let order_status = $.trim($("#order_status").html());
let order_url = $.trim($("#order_url").html())
if ($('#stripe_payment_intent_SCA_status').length) {
let payment_intent_redirect_action_handling = $.trim($('#stripe_payment_intent_redirect_action_handling').html())
let order_status = $.trim($('#order_status').html())
let order_url = $.trim($('#order_url').html())
if (payment_intent_redirect_action_handling === 'iframe') {
window.parent.postMessage('3DS-authentication-complete.' + order_status, '*');
return;
} else if (payment_intent_redirect_action_handling === 'redirect') {
waitingDialog.show(gettext("Confirming your payment …"));
if (payment_intent_redirect_action_handling === 'iframe') {
window.parent.postMessage('3DS-authentication-complete.' + order_status, '*')
return
} else if (payment_intent_redirect_action_handling === 'redirect') {
waitingDialog.show(gettext('Confirming your payment …'))
if (order_status === 'p') {
window.location.href = order_url + '?paid=yes';
} else {
window.location.href = order_url;
}
}
} else if ($("#stripe_payment_intent_next_action_redirect_url").length) {
let payment_intent_next_action_redirect_url = JSON.parse($("#stripe_payment_intent_next_action_redirect_url").html());
pretixstripe.handlePaymentRedirectAction(payment_intent_next_action_redirect_url);
} else if ($.trim($("#stripe_payment_intent_action_type").html()) === "promptpay_display_qr_code") {
waitingDialog.hide();
} else if ($.trim($("#stripe_payment_intent_action_type").html()) === "wechat_pay_display_qr_code") {
let payment_intent_client_secret = $.trim($("#stripe_payment_intent_client_secret").html());
pretixstripe.handleWechatAction(payment_intent_client_secret);
} else if ($.trim($("#stripe_payment_intent_action_type").html()) === "alipay_handle_redirect") {
let payment_intent_client_secret = $.trim($("#stripe_payment_intent_client_secret").html());
pretixstripe.handleAlipayAction(payment_intent_client_secret);
} else if ($("#stripe_payment_intent_client_secret").length) {
let payment_intent_client_secret = $.trim($("#stripe_payment_intent_client_secret").html());
pretixstripe.handleCardAction(payment_intent_client_secret);
}
if (order_status === 'p') {
window.location.href = order_url + '?paid=yes'
} else {
window.location.href = order_url
}
}
} else if ($('#stripe_payment_intent_next_action_redirect_url').length) {
let payment_intent_next_action_redirect_url = JSON.parse($('#stripe_payment_intent_next_action_redirect_url').html())
pretixstripe.handlePaymentRedirectAction(payment_intent_next_action_redirect_url)
} else if ($.trim($('#stripe_payment_intent_action_type').html()) === 'promptpay_display_qr_code') {
waitingDialog.hide()
} else if ($.trim($('#stripe_payment_intent_action_type').html()) === 'wechat_pay_display_qr_code') {
let payment_intent_client_secret = $.trim($('#stripe_payment_intent_client_secret').html())
pretixstripe.handleWechatAction(payment_intent_client_secret)
} else if ($.trim($('#stripe_payment_intent_action_type').html()) === 'alipay_handle_redirect') {
let payment_intent_client_secret = $.trim($('#stripe_payment_intent_client_secret').html())
pretixstripe.handleAlipayAction(payment_intent_client_secret)
} else if ($('#stripe_payment_intent_client_secret').length) {
let payment_intent_client_secret = $.trim($('#stripe_payment_intent_client_secret').html())
pretixstripe.handleCardAction(payment_intent_client_secret)
}
$(window).on("message onmessage", function (e) {
if (typeof e.originalEvent.data === "string" && e.originalEvent.data.startsWith('3DS-authentication-complete.')) {
waitingDialog.show(gettext("Confirming your payment …"));
$('#scacontainer').hide();
$('#continuebutton').removeClass('hidden');
$(window).on('message onmessage', function (e) {
if (typeof e.originalEvent.data === 'string' && e.originalEvent.data.startsWith('3DS-authentication-complete.')) {
waitingDialog.show(gettext('Confirming your payment …'))
$('#scacontainer').hide()
$('#continuebutton').removeClass('hidden')
if (e.originalEvent.data.split('.')[1] == 'p') {
window.location.href = $('#continuebutton').attr('href') + '?paid=yes';
} else {
window.location.href = $('#continuebutton').attr('href');
}
}
});
if (e.originalEvent.data.split('.')[1] == 'p') {
window.location.href = $('#continuebutton').attr('href') + '?paid=yes'
} else {
window.location.href = $('#continuebutton').attr('href')
}
}
})
if (!$(".stripe-container").length)
return;
if (!$('.stripe-container').length)
return
if (
$("input[name=payment][value=stripe]").is(':checked')
|| $("input[name=payment][value=stripe_sepa_debit]").is(':checked')
|| $("input[name=payment][value=stripe_affirm]").is(':checked')
|| $("input[name=payment][value=stripe_klarna]").is(':checked')
|| $(".payment-redo-form").length) {
pretixstripe.load();
} else {
$("input[name=payment]").change(function () {
if (['stripe', 'stripe_sepa_debit', 'stripe_affirm', 'stripe_klarna'].indexOf($(this).val()) > -1) {
pretixstripe.load();
}
})
}
if (
$('input[name=payment][value=stripe]').is(':checked')
|| $('input[name=payment][value=stripe_sepa_debit]').is(':checked')
|| $('input[name=payment][value=stripe_affirm]').is(':checked')
|| $('input[name=payment][value=stripe_klarna]').is(':checked')
|| $('.payment-redo-form').length) {
pretixstripe.load()
} else {
$('input[name=payment]').change(function () {
if (['stripe', 'stripe_sepa_debit', 'stripe_affirm', 'stripe_klarna'].indexOf($(this).val()) > -1) {
pretixstripe.load()
}
})
}
$("#stripe_other_card").click(
function (e) {
$("#stripe_card_payment_method_id").val("");
$("#stripe-current-card").slideUp();
$("#stripe-card-elements").slideDown();
$('#stripe_other_card').click(
function (e) {
$('#stripe_card_payment_method_id').val('')
$('#stripe-current-card').slideUp()
$('#stripe-card-elements').slideDown()
e.preventDefault();
return false;
}
);
e.preventDefault()
return false
}
)
if ($("#stripe-current-card").length) {
$("#stripe-card-elements").hide();
}
if ($('#stripe-current-card').length) {
$('#stripe-card-elements').hide()
}
$("#stripe_other_account").click(
function (e) {
$("#stripe_sepa_debit_payment_method_id").val("");
$("#stripe-current-account").slideUp();
// We're using a css-selector here instead of the id-selector,
// as we're hiding Stripe Elements *and* Django form fields
$('.stripe-sepa_debit-form').slideDown();
$('#stripe_other_account').click(
function (e) {
$('#stripe_sepa_debit_payment_method_id').val('')
$('#stripe-current-account').slideUp()
// We're using a css-selector here instead of the id-selector,
// as we're hiding Stripe Elements *and* Django form fields
$('.stripe-sepa_debit-form').slideDown()
e.preventDefault();
return false;
}
);
e.preventDefault()
return false
}
)
if ($("#stripe-current-account").length) {
// We're using a css-selector here instead of the id-selector,
// as we're hiding Stripe Elements *and* Django form fields
$('.stripe-sepa_debit-form').hide();
}
if ($('#stripe-current-account').length) {
// We're using a css-selector here instead of the id-selector,
// as we're hiding Stripe Elements *and* Django form fields
$('.stripe-sepa_debit-form').hide()
}
$('.stripe-container').closest("form").submit(
function () {
if ($("input[name=card_new]").length && !$("input[name=card_new]").prop('checked')) {
return null;
}
if (($("input[name=payment][value=stripe]").prop('checked') || $("input[name=payment][type=radio]").length === 0)
&& $("#stripe_card_payment_method_id").val() == "") {
pretixstripe.pm_request('card', pretixstripe.card);
return false;
}
$('.stripe-container').closest('form').submit(
function () {
if ($('input[name=card_new]').length && !$('input[name=card_new]').prop('checked')) {
return null
}
if (($('input[name=payment][value=stripe]').prop('checked') || $('input[name=payment][type=radio]').length === 0)
&& $('#stripe_card_payment_method_id').val() == '') {
pretixstripe.pm_request('card', pretixstripe.card)
return false
}
if (($("input[name=payment][value=stripe_sepa_debit]").prop('checked')) && $("#stripe_sepa_debit_payment_method_id").val() == "") {
pretixstripe.pm_request('sepa_debit', pretixstripe.sepa, {
billing_details: {
name: $("#id_payment_stripe_sepa_debit-accountname").val(),
email: $("#stripe_sepa_debit_email").val(),
address: {
line1: $("#id_payment_stripe_sepa_debit-line1").val(),
postal_code: $("#id_payment_stripe_sepa_debit-postal_code").val(),
city: $("#id_payment_stripe_sepa_debit-city").val(),
country: $("#id_payment_stripe_sepa_debit-country").val(),
}
}
});
return false;
}
}
);
});
if (($('input[name=payment][value=stripe_sepa_debit]').prop('checked')) && $('#stripe_sepa_debit_payment_method_id').val() == '') {
pretixstripe.pm_request('sepa_debit', pretixstripe.sepa, {
billing_details: {
name: $('#id_payment_stripe_sepa_debit-accountname').val(),
email: $('#stripe_sepa_debit_email').val(),
address: {
line1: $('#id_payment_stripe_sepa_debit-line1').val(),
postal_code: $('#id_payment_stripe_sepa_debit-postal_code').val(),
city: $('#id_payment_stripe_sepa_debit-city').val(),
country: $('#id_payment_stripe_sepa_debit-country').val(),
}
}
})
return false
}
}
)
})
+54 -3
View File
@@ -35,6 +35,7 @@ import copy
import inspect
import uuid
from collections import defaultdict
from datetime import time
from decimal import Decimal
from django import forms
@@ -52,6 +53,7 @@ from django.shortcuts import redirect
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.html import conditional_escape
from django.utils.timezone import now
from django.utils.translation import (
get_language, gettext_lazy as _, pgettext_lazy,
)
@@ -71,6 +73,7 @@ from pretix.base.services.cart import (
from pretix.base.services.cross_selling import CrossSellingService
from pretix.base.services.memberships import validate_memberships_in_order
from pretix.base.services.orders import perform_order
from pretix.base.services.payment import compute_payment_deadline
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import EventTask
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -870,6 +873,28 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
'attendee_name_parts': d
})
wd = self.cart_session.get('widget_data', {})
if wd.get('attendee-fix', '') == 'true':
for k, v in wd.items():
if v and k.startswith('attendee-name'):
o.append({
'attendee_name_parts': {
'disabled': True,
}
})
elif v and k.startswith('email'):
o.append({
'attendee_email': {
'disabled': True,
}
})
elif v and k.startswith('question-'):
o.append({
k[9:].upper(): {
'disabled': True,
}
})
return o
@cached_property
@@ -1344,6 +1369,11 @@ class PaymentStep(CartMixin, TemplateFlowStep):
self.request = request
self.request.pci_dss_payment_page = True
if "postpone" in request.POST and self._allow_postpone:
self.cart_session['payments_postpone'] = True
self.cart_session['payments'] = []
return redirect_to_url(self.get_next_url(request))
if "remove_payment" in request.POST:
self._remove_payment(request.POST["remove_payment"])
return redirect_to_url(self.get_step_url(request))
@@ -1432,20 +1462,41 @@ class PaymentStep(CartMixin, TemplateFlowStep):
ctx['providers'] = self.provider_forms
ctx['show_fees'] = any(p['fee'] for p in self.provider_forms)
if len(self.provider_forms) == 1:
ctx['selected'] = self.provider_forms[0]['provider'].identifier
elif 'payment' in self.request.POST:
if 'payment' in self.request.POST:
ctx['selected'] = self.request.POST['payment']
elif self.cart_session.get('payments_postpone') and self._allow_postpone:
ctx['selected'] = ''
elif len(self.provider_forms) == 1:
ctx['selected'] = self.provider_forms[0]['provider'].identifier
elif self.single_use_payment:
ctx['selected'] = self.single_use_payment['provider']
else:
ctx['selected'] = ''
ctx['allow_postpone'] = self._allow_postpone
if self._allow_postpone:
now_dt = now()
ctx['payment_deadline'] = compute_payment_deadline(
event=self.request.event,
sales_channel=self.request.sales_channel,
subevents={p.subevent for p in ctx['cart']['raw']},
now_dt=now_dt,
)
if ctx['payment_deadline'].time() != time(hour=23, minute=59, second=59):
ctx['payment_deadline_minutes'] = int((ctx['payment_deadline'] - now_dt).total_seconds() // 60)
return ctx
@cached_property
def _allow_postpone(self):
return self.request.sales_channel.identifier in self.request.event.settings.payment_choice_postpone_allowed_channels
def _is_allowed(self, prov, request):
return prov.is_allowed(request, total=self._total_order_value)
def is_completed(self, request, warn=False):
if self.cart_session.get('payments_postpone') and self._allow_postpone:
return True
if not self.cart_session.get('payments'):
if warn:
messages.error(request, _('Please select a payment method to proceed.'))
+3 -2
View File
@@ -247,14 +247,15 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
continue
if item.hidden_if_item_available:
time_available = item.hidden_if_item_available.is_available()
if item.hidden_if_item_available.has_variations:
item._dependency_available = any(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
# is_available on variant is evaluated called by available_variations
for var in item.hidden_if_item_available.available_variations
)
) and time_available
else:
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
time_available = item.hidden_if_item_available.is_available()
item._dependency_available = (q[0] == Quota.AVAILABILITY_OK) and time_available
if item._dependency_available and item.hidden_if_item_available_mode == Item.UNAVAIL_MODE_HIDDEN:
item._remove = True
@@ -128,6 +128,35 @@
{% endif %}
</div>
{% endif %}
{% if allow_postpone %}
<div class="panel panel-default">
<div class="panel-body row">
<div class="col-md-9 col-xs-12">
{% trans "Not sure yet? You can complete your order first and then select a payment method later." %}
<br>
<span class="text-muted">
{% if current_payments %}
{% trans "To do so, please first remove the payment methods you already selected above." %}
{% elif payment_deadline_minutes %}
{% blocktrans trimmed with minutes=payment_deadline_minutes %}
Your payment needs to be completed within {{ minutes }} minutes.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with deadline=payment_deadline|date:"SHORT_DATE_FORMAT" %}
Your payment needs to be completed by {{ deadline }}.
{% endblocktrans %}
{% endif %}
</span>
</div>
<div class="col-md-3 col-xs-12 text-right flip">
<button name="postpone" value="on" class="btn btn-primary"
{% if current_payments %}disabled{% endif %}>
{% trans "Proceed without selection" %}
</button>
</div>
</div>
</div>
{% endif %}
<div class="row checkout-button-row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"
@@ -23,7 +23,11 @@
{% endblocktrans %} ::
{% endif %}
{% elif subevent %}
{{ subevent.get_date_range_display }} ::
{% if subevent.name|upper != event.name|upper %}
{# The |upper is a trick to force LazyI18nString→str conversion before comparison #}
{{ subevent.name }} ::
{% endif %}
{{ subevent.get_date_range_display_with_times }} ::
{% endif %}
{% endblock %}
@@ -3,7 +3,7 @@
{% load escapejson %}
{% if payment_qr_codes %}
<div class="tabcontainer col-md-6 col-sm-6 hidden-xs text-center js-only blank-after">
<div class="tabcontainer col-md-6 col-sm-6 col-xs-12 text-center js-only blank-after">
<div id="banktransfer_qrcodes_tabs_content" class="tabpanels blank-after">
{% for code_info in payment_qr_codes %}
<div id="banktransfer_qrcodes_{{ code_info.id }}"
@@ -22,12 +22,14 @@
{% trans "Change account information" %}
</a>
</li>
<li>
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}">
{% icon "key" %}
{% trans "Change password" %}
</a>
</li>
{% if not customer.provider %}
<li>
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}">
{% icon "key" %}
{% trans "Change password" %}
</a>
</li>
{% endif %}
</ul>
</dd>
<dd>
+6
View File
@@ -54,6 +54,7 @@ from pretix.base.models import Customer, InvoiceAddress, Order, OrderPosition
from pretix.base.services.mail import mail
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import customer_created, customer_signed_in
from pretix.helpers import OF_SELF
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.models import KnownDomain
@@ -280,6 +281,11 @@ class SetPasswordView(FormView):
def form_valid(self, form):
with transaction.atomic():
# Re-check token in transaction to prevent race condition
self.customer = Customer.objects.select_for_update(of=OF_SELF).get(pk=self.customer.pk)
if not TokenGenerator().check_token(self.customer, self.request.GET.get('token', '')):
return HttpResponseRedirect(self.get_success_url())
self.customer.set_password(form.cleaned_data['password'])
self.customer.is_verified = True
self.customer.save()
+9 -8
View File
@@ -122,19 +122,20 @@ def widget_css_etag(request, version, **kwargs):
return f'{_get_source_cache_key(version)}-{request.organizer.cache.get_or_set("css_version", default=lambda: int(time.time()))}'
# use vite by default, serve old vue2-based widget only for widget_vue2_origins
def _use_vite(request):
if getattr(settings, 'PRETIX_WIDGET_VITE', False) or "beta" in request.GET:
return True
if getattr(settings, 'PRETIX_WIDGET_VUE', False) or "legacy" in request.GET:
return False
origin = request.META.get('HTTP_ORIGIN', '')
gs = GlobalSettingsObject()
vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='')
if vite_origins and not origin:
vue_origins = gs.settings.get('widget_vue2_origins', as_type=str, default='')
if vue_origins and not origin:
referer = request.META.get('HTTP_REFERER', '')
origin = '/'.join(referer.split('/', 3)[:3])
if origin and vite_origins:
origins_list = [o.strip() for o in vite_origins.strip().splitlines() if o.strip()]
return origin in origins_list
return False
if origin and vue_origins:
origins_list = [o.strip() for o in vue_origins.strip().splitlines() if o.strip()]
return origin not in origins_list
return True
def widget_js_etag(request, version, lang, **kwargs):
+1 -1
View File
@@ -913,7 +913,7 @@ VITE_DEV_SERVER_PORT = 5173
VITE_DEV_SERVER = f"http://localhost:{VITE_DEV_SERVER_PORT}"
VITE_DEV_MODE = DEBUG
VITE_IGNORE = False # Used to ignore `collectstatic`/`rebuild`
PRETIX_WIDGET_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0')
PRETIX_WIDGET_VUE = os.environ.get('PRETIX_WIDGET_VUE', '') not in ('', '0')
if DEBUG:
# Reload if settings file changes
+169 -171
View File
@@ -1,195 +1,193 @@
$(function () {
"use strict";
'use strict'
// Responses are expected to only depend on the GET parameters passed, so we can have a little client-side cache
// to prevent fetching the same thing many times.
var responseCache = {};
// Responses are expected to only depend on the GET parameters passed, so we can have a little client-side cache
// to prevent fetching the same thing many times.
let responseCache = {}
const cleanName = (name) => {
// Remove form prefix
name = name.split("-").pop();
// Remove settings prefix
name = name.replace(/^invoice_address_from_/, "");
return name
}
const cleanName = (name) => {
// Remove form prefix
name = name.split('-').pop()
// Remove settings prefix
name = name.replace(/^invoice_address_from_/, '')
return name
}
$("[data-address-information-url]").each(function () {
let xhr;
const form = $(this);
const dependencies = $(this).find("[data-trigger-address-info]");
const loader = $("<span class='fa fa-cog fa-spin'></span>").hide().prependTo(dependencies.closest(".form-group").find("label").first())
const baseUrl = this.getAttribute('data-address-information-url')
const isAnyRequired = dependencies.toArray().some(function (e) { return $(e).closest(".form-group").is(".required") });
$('[data-address-information-url]').each(function () {
let xhr
const form = $(this)
const dependencies = $(this).find('[data-trigger-address-info]')
const loader = $('<span class=\'fa fa-cog fa-spin\'></span>').hide().prependTo(dependencies.closest('.form-group').find('label').first())
const baseUrl = this.getAttribute('data-address-information-url')
const isAnyRequired = dependencies.toArray().some(function (e) { return $(e).closest('.form-group').is('.required') })
const dependents = {
'city': form.find("input[name$=city]"),
'zipcode': form.find("input[name$=zipcode]"),
'street': form.find("textarea[name$=street]"),
'state': form.find("select[name$=state]"),
'vat_id': form.find("input[name$=vat_id]"),
};
const dependents = {
city: form.find('input[name$=city]'),
zipcode: form.find('input[name$=zipcode]'),
street: form.find('textarea[name$=street]'),
state: form.find('select[name$=state]'),
vat_id: form.find('input[name$=vat_id]'),
}
form.find("select[name*=transmission_], textarea[name*=transmission_], input[name*=transmission_]").each(function () {
dependents[cleanName($(this).attr("name"))] = $(this)
})
form.find('select[name*=transmission_], textarea[name*=transmission_], input[name*=transmission_]').each(function () {
dependents[cleanName($(this).attr('name'))] = $(this)
})
const dependentsDisabled = [];
for (var k in dependents) {
if (dependents[k].prop("disabled")) {
dependentsDisabled.push(k);
}
}
const dependentsDisabled = []
for (let k in dependents) {
if (dependents[k].prop('disabled')) {
dependentsDisabled.push(k)
}
}
if (!Object.values(dependents).some((el) => el.length)) {
// No address fields found, do not create request
return;
}
if (!Object.values(dependents).some((el) => el.length)) {
// No address fields found, do not create request
return
}
const update_form = function (data) {
var selected_state = dependents.state.prop("data-selected-value");
if (selected_state) dependents.state.prop("data-selected-value", "");
dependents.state.find("option:not([value=''])").remove();
$.each(data.data, function (k, s) {
var o = $("<option>").attr("value", s.code).text(s.name);
if (selected_state === s.code) o.prop("selected", true);
dependents.state.append(o);
});
const update_form = function (data) {
let selected_state = dependents.state.prop('data-selected-value')
if (selected_state) dependents.state.prop('data-selected-value', '')
dependents.state.find('option:not([value=\'\'])').remove()
$.each(data.data, function (k, s) {
let o = $('<option>').attr('value', s.code).text(s.name)
if (selected_state === s.code) o.prop('selected', true)
dependents.state.append(o)
})
if (dependents.transmission_type) {
var selected_transmission_type = dependents.transmission_type.prop("data-selected-value");
if (selected_transmission_type) dependents.transmission_type.prop("data-selected-value", "");
dependents.transmission_type.find("option:not([value='']):not([value='-'])").remove();
if (dependents.transmission_type) {
let selected_transmission_type = dependents.transmission_type.prop('data-selected-value')
if (selected_transmission_type) dependents.transmission_type.prop('data-selected-value', '')
dependents.transmission_type.find('option:not([value=\'\']):not([value=\'-\'])').remove()
if (!data.transmission_type.visible) {
selected_transmission_type = "email";
}
if (!data.transmission_type.visible) {
selected_transmission_type = 'email'
}
$.each(data.transmission_types, function (k, s) {
var o = $("<option>").attr("value", s.code).text(s.name);
if (selected_transmission_type === s.code) {
o.prop("selected", true);
}
dependents.transmission_type.append(o);
});
$.each(data.transmission_types, function (k, s) {
let o = $('<option>').attr('value', s.code).text(s.name)
if (selected_transmission_type === s.code) {
o.prop('selected', true)
}
dependents.transmission_type.append(o)
})
}
}
for (var k in dependents) {
const options = data[k],
dependent = dependents[k]
let visible = 'visible' in options ? options.visible : true
for (var k in dependents) {
const options = data[k],
dependent = dependents[k];
let visible = 'visible' in options ? options.visible : true;
if (dependent.is('[data-display-dependency]')) {
const dependency = $(dependent.attr('data-display-dependency'))
visible = visible && (
(dependency.attr('type') === 'checkbox' || dependency.attr('type') === 'radio') ? dependency.prop('checked') : !!dependency.val()
)
}
if (dependent.is("[data-display-dependency]")) {
const dependency = $(dependent.attr("data-display-dependency"));
visible = visible && (
(dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val()
);
}
if ('label' in options) {
dependent.closest('.form-group').find('.control-label').text(options.label)
}
if ('helptext_visible' in options) {
dependent.closest('.form-group').find('.help-block').toggle(options.helptext_visible)
}
if ('label' in options) {
dependent.closest(".form-group").find(".control-label").text(options.label);
}
if ('helptext_visible' in options) {
dependent.closest(".form-group").find(".help-block").toggle(options.helptext_visible);
}
const required = 'required' in options && visible && (
(options.required === 'if_any' && isAnyRequired)
|| (options.required === true)
)
dependent.closest('.form-group').toggle(visible).toggleClass('required', required)
dependent.prop('required', required)
const required = 'required' in options && visible && (
(options.required === 'if_any' && isAnyRequired) ||
(options.required === true)
);
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
dependent.prop("required", required);
const label = dependent.closest('.form-group').find('label')
const labelRequired = label.find('.label-required')
if (!required) {
labelRequired.remove()
} else if (!labelRequired.length) {
label.append('<i class="label-required">' + gettext('required') + '</i>')
}
}
for (var k in dependents) dependents[k].prop('disabled', dependentsDisabled.includes(k))
loader.hide()
}
const label = dependent.closest(".form-group").find("label");
const labelRequired = label.find(".label-required");
if (!required) {
labelRequired.remove();
} else if (!labelRequired.length) {
label.append('<i class="label-required">' + gettext('required') + '</i>')
}
}
for (var k in dependents) dependents[k].prop("disabled", dependentsDisabled.includes(k));
loader.hide();
}
const update = function (ev) {
dependents.state.prop('data-selected-value', dependents.state.val())
if (dependents.transmission_type) {
dependents.transmission_type.prop('data-selected-value', dependents.transmission_type.val())
}
const update = function (ev) {
dependents.state.prop("data-selected-value", dependents.state.val());
if (dependents.transmission_type) {
dependents.transmission_type.prop("data-selected-value", dependents.transmission_type.val());
}
for (let k in dependents) dependents[k].prop('disabled', true)
loader.show()
let url = new URL(baseUrl, location.href)
// Address depends on all annotated fields
form.find('[data-trigger-address-info]').each(function () {
// Remove prefix of the form to get actual field name
if (($(this).attr('type') === 'radio' || $(this).attr('type') === 'checkbox') && !$(this).prop('checked')) {
return
}
url.searchParams.append(cleanName($(this).attr('name')), $(this).val())
})
if (dependents.transmission_type) {
url.searchParams.append('transmission_type_required', !dependents.transmission_type.find('option[value=\'-\']').length)
}
for (var k in dependents) dependents[k].prop("disabled", true);
loader.show();
var url = new URL(baseUrl, location.href);
// Address depends on all annotated fields
form.find("[data-trigger-address-info]").each(function () {
// Remove prefix of the form to get actual field name
if (($(this).attr("type") === "radio" || $(this).attr("type") === "checkbox") && !$(this).prop("checked")) {
return
}
url.searchParams.append(cleanName($(this).attr("name")), $(this).val());
})
if (dependents.transmission_type) {
url.searchParams.append("transmission_type_required", !dependents.transmission_type.find("option[value='-']").length);
}
if (xhr && url in responseCache) {
if (responseCache[url] == xhr) {
// already requested this, but XHR is still running and will resolve promise
// only re-resolve promise for JSON-data in responseCache[url]
return
} else {
// abort current xhr as it is not the one we want
// aborting deletes responseCache[url] but async
xhr.abort()
}
}
if (xhr && url in responseCache) {
if (responseCache[url] == xhr) {
// already requested this, but XHR is still running and will resolve promise
// only re-resolve promise for JSON-data in responseCache[url]
return;
} else {
// abort current xhr as it is not the one we want
// aborting deletes responseCache[url] but async
xhr.abort();
}
}
if (!(url in responseCache)) {
responseCache[url] = xhr = $.ajax({
dataType: 'json',
url: url,
timeout: 3000,
})
}
if (!(url in responseCache)) {
responseCache[url] = xhr = $.ajax({
dataType: "json",
url: url,
timeout: 3000,
});
}
Promise.resolve(responseCache[url]).then(function (data) {
responseCache[url] = data
update_form(data)
}).catch(function () {
delete responseCache[url]
// In case of errors, show everything and require nothing, we can still handle errors in backend
for (let k in dependents) {
const dependent = dependents[k],
visible = true,
required = false
Promise.resolve(responseCache[url]).then(function (data) {
responseCache[url] = data;
update_form(data);
}).catch(function () {
delete responseCache[url];
// In case of errors, show everything and require nothing, we can still handle errors in backend
for (var k in dependents) {
const dependent = dependents[k],
visible = true,
required = false;
dependent.closest('.form-group').toggle(visible).toggleClass('required', required)
dependent.prop('required', required).prop('disabled', dependentsDisabled.includes(k))
}
}).finally(function () {
loader.hide()
})
}
update()
dependencies.on('change', update)
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
dependent.prop("required", required).prop("disabled", dependentsDisabled.includes(k));
}
}).finally(function () {
loader.hide();
});
};
update();
dependencies.on("change", update);
if (dependents.vat_id && dependents.transmission_type && dependents.transmission_peppol_participant_id) {
// In Belgium, the VAT ID is built from "BE" + the company ID. The Peppol ID also needs to be built
// from the company ID with ID scheme 0208. We can save users some knowing and typing by filling this in!
if (!dependents.transmission_peppol_participant_id.val()) {
const fill_peppol_id = function () {
const vatId = dependents.vat_id.val();
if (vatId && vatId.startsWith("BE") && dependents.transmission_type.val() === "peppol") {
dependents.transmission_peppol_participant_id.val("0208:" + vatId.substring(2).replaceAll(".", ""))
}
}
dependents.vat_id.add(dependents.transmission_type).on("change", fill_peppol_id);
dependents.transmission_peppol_participant_id.one("change", () => {
dependents.vat_id.add(dependents.transmission_type).unbind("change", fill_peppol_id)
});
}
}
});
});
if (dependents.vat_id && dependents.transmission_type && dependents.transmission_peppol_participant_id) {
// In Belgium, the VAT ID is built from "BE" + the company ID. The Peppol ID also needs to be built
// from the company ID with ID scheme 0208. We can save users some knowing and typing by filling this in!
if (!dependents.transmission_peppol_participant_id.val()) {
const fill_peppol_id = function () {
const vatId = dependents.vat_id.val()
if (vatId && vatId.startsWith('BE') && dependents.transmission_type.val() === 'peppol') {
dependents.transmission_peppol_participant_id.val('0208:' + vatId.substring(2).replaceAll('.', ''))
}
}
dependents.vat_id.add(dependents.transmission_type).on('change', fill_peppol_id)
dependents.transmission_peppol_participant_id.one('change', () => {
dependents.vat_id.add(dependents.transmission_type).unbind('change', fill_peppol_id)
})
}
}
})
})
@@ -1,10 +1,10 @@
var check = function () {
$.getJSON(location.href + '&ajax=1', function (data, status) {
if (data.redirect) {
location.href = data.redirect;
} else {
window.setTimeout(check, 500);
}
});
let check = function () {
$.getJSON(location.href + '&ajax=1', function (data, _status) {
if (data.redirect) {
location.href = data.redirect
} else {
window.setTimeout(check, 500)
}
})
}
window.setTimeout(check, 500);
window.setTimeout(check, 500)
+328 -330
View File
@@ -1,359 +1,357 @@
/*global $, gettext */
var async_task_id = null;
var async_task_timeout = null;
var async_task_check_url = null;
var async_task_old_url = null;
var async_task_is_download = false;
var async_task_is_long = false;
var async_task_dont_redirect = false;
/* global gettext */
let async_task_id = null
let async_task_timeout = null
let async_task_check_url = null
let async_task_old_url = null
let async_task_is_download = false
let async_task_is_long = false
let async_task_dont_redirect = false
var async_task_status_messages = {
// These are functions in order to be lazily evaluated after the gettext file is loaded
long_task_started: () => gettext(
'Your request is currently being processed. Depending on the size of your event, this might take up to ' +
'a few minutes.'
),
long_task_pending: () => gettext(
'Your request has been queued on the server and will soon be ' +
'processed.'
),
short_task: () => gettext(
'Your request arrived on the server but we still wait for it to be ' +
'processed. If this takes longer than two minutes, please contact us or go ' +
'back in your browser and try again.'
)
};
function async_task_schedule_check(context, timeout) {
"use strict";
async_task_timeout = window.setTimeout(function() {
$.ajax(
{
'type': 'GET',
'url': async_task_check_url,
'success': async_task_check_callback,
'error': async_task_check_error,
'context': context,
'dataType': 'json'
}
);
}, timeout);
let async_task_status_messages = {
// These are functions in order to be lazily evaluated after the gettext file is loaded
long_task_started: () => gettext(
'Your request is currently being processed. Depending on the size of your event, this might take up to '
+ 'a few minutes.'
),
long_task_pending: () => gettext(
'Your request has been queued on the server and will soon be '
+ 'processed.'
),
short_task: () => gettext(
'Your request arrived on the server but we still wait for it to be '
+ 'processed. If this takes longer than two minutes, please contact us or go '
+ 'back in your browser and try again.'
)
}
function async_task_on_success(data) {
"use strict";
if ((async_task_is_download && data.success) || async_task_dont_redirect) {
waitingDialog.hide();
if (location.href.indexOf("async_id") !== -1) {
history.replaceState({}, "pretix", async_task_old_url);
}
}
if (!async_task_dont_redirect) {
$(window).one("pageshow", function (e) {
// hide waitingDialog when using browser's history back
waitingDialog.hide();
});
if (async_task_is_download && window.self !== window.top) {
// if in an iframe, force to download an async_task_is_download
// e.g. pretix-reseller embeds order-page in iframe, which would cause ticket-PDFs to be displayed inline
var a = document.createElement("a");
a.href = data.redirect;
a.download = "";
a.target = "_blank";
a.click();
} else {
location.href = data.redirect;
}
}
$(this).trigger('pretix:async-task-success', data);
function async_task_schedule_check (context, timeout) {
'use strict'
async_task_timeout = window.setTimeout(function () {
$.ajax(
{
type: 'GET',
url: async_task_check_url,
success: async_task_check_callback,
error: async_task_check_error,
context: context,
dataType: 'json'
}
)
}, timeout)
}
function async_task_check_callback(data, textStatus, jqXHR) {
"use strict";
if (data.ready && data.redirect) {
async_task_on_success.call(this, data);
return;
}
if (typeof data.percentage === "number") {
waitingDialog.setProgress(data.percentage);
}
if (typeof data.steps === "object" && Array.isArray(data.steps)) {
waitingDialog.setSteps(data.steps);
}
async_task_schedule_check(this, 250);
async_task_update_status(data);
function async_task_on_success (data) {
'use strict'
if ((async_task_is_download && data.success) || async_task_dont_redirect) {
waitingDialog.hide()
if (location.href.indexOf('async_id') !== -1) {
history.replaceState({}, 'pretix', async_task_old_url)
}
}
if (!async_task_dont_redirect) {
$(window).one('pageshow', function (e) {
// hide waitingDialog when using browser's history back
waitingDialog.hide()
})
if (async_task_is_download && window.self !== window.top) {
// if in an iframe, force to download an async_task_is_download
// e.g. pretix-reseller embeds order-page in iframe, which would cause ticket-PDFs to be displayed inline
let a = document.createElement('a')
a.href = data.redirect
a.download = ''
a.target = '_blank'
a.click()
} else {
location.href = data.redirect
}
}
$(this).trigger('pretix:async-task-success', data)
}
function async_task_update_status(data) {
if (async_task_is_long) {
if (data.started) {
waitingDialog.setStatus(async_task_status_messages.long_task_started());
} else {
waitingDialog.setStatus(async_task_status_messages.long_task_pending());
}
} else {
waitingDialog.setStatus(async_task_status_messages.short_task());
}
function async_task_check_callback (data, textStatus, jqXHR) {
'use strict'
if (data.ready && data.redirect) {
async_task_on_success.call(this, data)
return
}
if (typeof data.percentage === 'number') {
waitingDialog.setProgress(data.percentage)
}
if (typeof data.steps === 'object' && Array.isArray(data.steps)) {
waitingDialog.setSteps(data.steps)
}
async_task_schedule_check(this, 250)
async_task_update_status(data)
}
function async_task_replace_page(target, new_html) {
"use strict";
waitingDialog.hide();
$(target).html(new_html);
setup_basics($(target));
form_handlers($(target));
setup_collapsible_details($(target));
window.setTimeout(function () { $(window).scrollTop(0) }, 200)
$(document).trigger("pretix:bind-forms");
function async_task_update_status (data) {
if (async_task_is_long) {
if (data.started) {
waitingDialog.setStatus(async_task_status_messages.long_task_started())
} else {
waitingDialog.setStatus(async_task_status_messages.long_task_pending())
}
} else {
waitingDialog.setStatus(async_task_status_messages.short_task())
}
}
function async_task_check_error(jqXHR, textStatus, errorThrown) {
"use strict";
var respdom = $(jqXHR.responseText);
var c = respdom.filter('.container');
if (jqXHR.status === 401 && jqXHR.getResponseHeader("X-Login-Url")) {
window.location = jqXHR.getResponseHeader("X-Login-Url") + "?next=" + encodeURIComponent(location.pathname + location.search + location.hash);
return;
}
if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) {
// This is a failed form validation, let's just use it
$("body").data('ajaxing', false);
async_task_replace_page("body", jqXHR.responseText.substring(
jqXHR.responseText.indexOf("<body"),
jqXHR.responseText.indexOf("</body")
));
} else if (c.length > 0) {
// This is some kind of 500/404/403 page, show it in an overlay
$("body").data('ajaxing', false);
waitingDialog.hide();
if (location.href.indexOf("async_id") !== -1) {
history.replaceState({}, "pretix", async_task_old_url);
}
ajaxErrDialog.show(c.first().html());
} else {
if (jqXHR.status >= 400 && jqXHR.status < 500) {
$("body").data('ajaxing', false);
waitingDialog.hide();
alert(gettext('An error of type {code} occurred.').replace(/\{code\}/, jqXHR.status));
} else {
// 500 can be an application error or overload in some cases :(
waitingDialog.setStatus(gettext('We currently cannot reach the server, but we keep trying.' +
' Last error code: {code}').replace(/\{code\}/, jqXHR.status));
async_task_schedule_check(this, 5000);
}
}
function async_task_replace_page (target, new_html) {
'use strict'
waitingDialog.hide()
$(target).html(new_html)
setup_basics($(target))
form_handlers($(target))
setup_collapsible_details($(target))
window.setTimeout(function () { $(window).scrollTop(0) }, 200)
$(document).trigger('pretix:bind-forms')
}
function async_task_callback(data, jqXHR, status) {
"use strict";
$("body").data('ajaxing', false);
if (data.redirect) {
async_task_on_success.call(this, data);
return;
}
var check_url = new URL(data.check_url, window.location);
if (async_task_dont_redirect) {
check_url.searchParams.set('ajax_dont_redirect', '1');
}
async_task_id = data.async_id;
async_task_check_url = check_url.toString();
async_task_schedule_check(this, 100);
async_task_update_status(data);
if (location.href.indexOf("async_id") === -1) {
history.pushState({}, "Waiting", async_task_check_url.replace(/ajax=1/, ''));
}
function async_task_check_error (jqXHR, textStatus, errorThrown) {
'use strict'
let respdom = $(jqXHR.responseText)
let c = respdom.filter('.container')
if (jqXHR.status === 401 && jqXHR.getResponseHeader('X-Login-Url')) {
window.location = jqXHR.getResponseHeader('X-Login-Url') + '?next=' + encodeURIComponent(location.pathname + location.search + location.hash)
return
}
if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) {
// This is a failed form validation, let's just use it
$('body').data('ajaxing', false)
async_task_replace_page('body', jqXHR.responseText.substring(
jqXHR.responseText.indexOf('<body'),
jqXHR.responseText.indexOf('</body')
))
} else if (c.length > 0) {
// This is some kind of 500/404/403 page, show it in an overlay
$('body').data('ajaxing', false)
waitingDialog.hide()
if (location.href.indexOf('async_id') !== -1) {
history.replaceState({}, 'pretix', async_task_old_url)
}
ajaxErrDialog.show(c.first().html())
} else {
if (jqXHR.status >= 400 && jqXHR.status < 500) {
$('body').data('ajaxing', false)
waitingDialog.hide()
alert(gettext('An error of type {code} occurred.').replace(/\{code\}/, jqXHR.status))
} else {
// 500 can be an application error or overload in some cases :(
waitingDialog.setStatus(gettext('We currently cannot reach the server, but we keep trying.'
+ ' Last error code: {code}').replace(/\{code\}/, jqXHR.status))
async_task_schedule_check(this, 5000)
}
}
}
function async_task_error(jqXHR, textStatus, errorThrown) {
"use strict";
$("body").data('ajaxing', false);
if (jqXHR.status === 401 && jqXHR.getResponseHeader("X-Login-Url")) {
window.location = jqXHR.getResponseHeader("X-Login-Url") + "?next=" + encodeURIComponent(location.pathname + location.search + location.hash);
return;
}
waitingDialog.hide();
if (textStatus === "timeout") {
alert(gettext("The request took too long. Please try again."));
} else if (jqXHR.responseText.indexOf('<html') > 0) {
var respdom = $(jqXHR.responseText);
var c = respdom.filter('.container');
if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) {
if (respdom.filter('#page-wrapper') && $('#page-wrapper').length) {
// This is a failed form validation, let's just use it
async_task_replace_page("#page-wrapper", respdom.find("#page-wrapper").html());
} else {
async_task_replace_page("body", jqXHR.responseText.substring(
jqXHR.responseText.indexOf("<body"),
jqXHR.responseText.indexOf("</body")
));
document.dispatchEvent(new Event("pretix:async-task-error"))
function async_task_callback (data, jqXHR, status) {
'use strict'
$('body').data('ajaxing', false)
if (data.redirect) {
async_task_on_success.call(this, data)
return
}
let check_url = new URL(data.check_url, window.location)
if (async_task_dont_redirect) {
check_url.searchParams.set('ajax_dont_redirect', '1')
}
async_task_id = data.async_id
async_task_check_url = check_url.toString()
async_task_schedule_check(this, 100)
}
async_task_update_status(data)
} else if (c.length > 0) {
// This is some kind of 500/404/403 page, show it in an overlay
ajaxErrDialog.show(c.first().html());
} else {
alert(gettext('An error of type {code} occurred.').replace(/\{code\}/, jqXHR.status));
}
} else {
if (jqXHR.status >= 400 && jqXHR.status < 500) {
alert(gettext('An error of type {code} occurred.').replace(/\{code\}/, jqXHR.status));
} else {
alert(gettext('We currently cannot reach the server. Please try again. ' +
'Error code: {code}').replace(/\{code\}/, jqXHR.status));
}
}
if (location.href.indexOf('async_id') === -1) {
history.pushState({}, 'Waiting', async_task_check_url.replace(/ajax=1/, ''))
}
}
function async_task_error (jqXHR, textStatus, errorThrown) {
'use strict'
$('body').data('ajaxing', false)
if (jqXHR.status === 401 && jqXHR.getResponseHeader('X-Login-Url')) {
window.location = jqXHR.getResponseHeader('X-Login-Url') + '?next=' + encodeURIComponent(location.pathname + location.search + location.hash)
return
}
waitingDialog.hide()
if (textStatus === 'timeout') {
alert(gettext('The request took too long. Please try again.'))
} else if (jqXHR.responseText.indexOf('<html') > 0) {
let respdom = $(jqXHR.responseText)
let c = respdom.filter('.container')
if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) {
if (respdom.filter('#page-wrapper') && $('#page-wrapper').length) {
// This is a failed form validation, let's just use it
async_task_replace_page('#page-wrapper', respdom.find('#page-wrapper').html())
} else {
async_task_replace_page('body', jqXHR.responseText.substring(
jqXHR.responseText.indexOf('<body'),
jqXHR.responseText.indexOf('</body')
))
document.dispatchEvent(new Event('pretix:async-task-error'))
}
} else if (c.length > 0) {
// This is some kind of 500/404/403 page, show it in an overlay
ajaxErrDialog.show(c.first().html())
} else {
alert(gettext('An error of type {code} occurred.').replace(/\{code\}/, jqXHR.status))
}
} else {
if (jqXHR.status >= 400 && jqXHR.status < 500) {
alert(gettext('An error of type {code} occurred.').replace(/\{code\}/, jqXHR.status))
} else {
alert(gettext('We currently cannot reach the server. Please try again. '
+ 'Error code: {code}').replace(/\{code\}/, jqXHR.status))
}
}
}
$(function () {
"use strict";
$("body").on('submit', 'form[data-asynctask]', function (e) {
// Not supported on IE, may lead to wrong results, but we don't support IE in the backend anymore
var submitter = e.originalEvent ? e.originalEvent.submitter : null;
'use strict'
$('body').on('submit', 'form[data-asynctask]', function (e) {
// Not supported on IE, may lead to wrong results, but we don't support IE in the backend anymore
let submitter = e.originalEvent ? e.originalEvent.submitter : null
if (submitter && submitter.hasAttribute("data-no-asynctask")) {
return;
}
if (submitter && submitter.hasAttribute('data-no-asynctask')) {
return
}
e.preventDefault();
$(this).removeClass("dirty"); // Avoid problems with are-you-sure.js
if ($("body").data('ajaxing')) {
return;
}
async_task_id = null;
async_task_is_download = $(this).is("[data-asynctask-download]");
async_task_dont_redirect = $(this).is("[data-asynctask-no-redirect]");
async_task_is_long = $(this).is("[data-asynctask-long]");
async_task_old_url = location.href;
$("body").data('ajaxing', true);
waitingDialog.show(
$(this).attr("data-asynctask-headline") || gettext('We are processing your request …'),
$(this).attr("data-asynctask-text") || '',
gettext(
'We are currently sending your request to the server. If this takes longer ' +
'than one minute, please check your internet connection and then reload ' +
'this page and try again.'
)
);
e.preventDefault()
$(this).removeClass('dirty') // Avoid problems with are-you-sure.js
if ($('body').data('ajaxing')) {
return
}
async_task_id = null
async_task_is_download = $(this).is('[data-asynctask-download]')
async_task_dont_redirect = $(this).is('[data-asynctask-no-redirect]')
async_task_is_long = $(this).is('[data-asynctask-long]')
async_task_old_url = location.href
$('body').data('ajaxing', true)
waitingDialog.show(
$(this).attr('data-asynctask-headline') || gettext('We are processing your request …'),
$(this).attr('data-asynctask-text') || '',
gettext(
'We are currently sending your request to the server. If this takes longer '
+ 'than one minute, please check your internet connection and then reload '
+ 'this page and try again.'
)
)
var action = this.action;
var formData = new FormData(this);
formData.append('ajax', '1');
if (async_task_dont_redirect) {
formData.append('ajax_dont_redirect', '1');
}
if (submitter && submitter.name) {
formData.append(submitter.name, submitter.value);
}
if (submitter && submitter.getAttribute("formaction")) {
action = submitter.getAttribute("formaction");
}
$.ajax(
{
'type': 'POST',
'url': action,
'data': formData,
processData: false,
contentType: false,
'success': async_task_callback,
'error': async_task_error,
'context': this,
'dataType': 'json',
'timeout': 60000,
}
);
});
let action = this.action
let formData = new FormData(this)
formData.append('ajax', '1')
if (async_task_dont_redirect) {
formData.append('ajax_dont_redirect', '1')
}
if (submitter && submitter.name) {
formData.append(submitter.name, submitter.value)
}
if (submitter && submitter.getAttribute('formaction')) {
action = submitter.getAttribute('formaction')
}
$.ajax(
{
type: 'POST',
url: action,
data: formData,
processData: false,
contentType: false,
success: async_task_callback,
error: async_task_error,
context: this,
dataType: 'json',
timeout: 60000,
}
)
})
window.addEventListener("pageshow", function (evt) {
// In Safari, if you submit an async task, then get redirected, then go back,
// Safari won't reload the HTML from disk cache but instead reuse the DOM of the
// previous request, thus not clearing the "loading" state.
if (evt.persisted && $("body").hasClass("loading")) {
setTimeout(function () {
window.location.reload();
}, 10);
}
}, false);
window.addEventListener('pageshow', function (evt) {
// In Safari, if you submit an async task, then get redirected, then go back,
// Safari won't reload the HTML from disk cache but instead reuse the DOM of the
// previous request, thus not clearing the "loading" state.
if (evt.persisted && $('body').hasClass('loading')) {
setTimeout(function () {
window.location.reload()
}, 10)
}
}, false)
$("#ajaxerr").on("click", ".ajaxerr-close", ajaxErrDialog.hide);
$("#loadingmodal").on("cancel", function() {
return false;
});
$("#loadingmodal").prop("closedBy", "none");
});
$('#ajaxerr').on('click', '.ajaxerr-close', ajaxErrDialog.hide)
$('#loadingmodal').on('cancel', function () {
return false
})
$('#loadingmodal').prop('closedBy', 'none')
})
var waitingDialog = {
show: function (title, text, status) {
"use strict";
this.setTitle(title);
this.setText(text);
this.setStatus(status || gettext('If this takes longer than a few minutes, please contact us.'));
this.setProgress(null);
this.setSteps(null);
document.getElementById("loadingmodal").showModal();
},
hide: function () {
"use strict";
document.getElementById("loadingmodal").close();
},
setTitle: function(title) {
$("#loadingmodal .modal-card-title").text(title);
},
setStatus: function(statusText) {
$("#loadingmodal p.status").text(statusText);
},
setText: function(text) {
if (text)
$("#loadingmodal .modal-card-description").text(text).show();
else
$("#loadingmodal .modal-card-description").hide();
},
setProgress: function(percentage) {
if (typeof percentage === 'number') {
$("#loadingmodal .progress").show();
$("#loadingmodal .progress .progress-bar").css("width", percentage + "%");
} else {
$("#loadingmodal .progress").hide();
}
},
setSteps: function(steps) {
var $steps = $("#loadingmodal .steps");
if (steps) {
$steps.html("").show()
for (var step of steps) {
$steps.append(
$("<span>").addClass("fa fa-fw")
.toggleClass("fa-check text-success", step.done)
.toggleClass("fa-cog fa-spin text-muted", !step.done)
).append(
$("<span>").text(step.label)
).append(
$("<br>")
)
}
} else {
$steps.hide();
}
}
};
show: function (title, text, status) {
'use strict'
this.setTitle(title)
this.setText(text)
this.setStatus(status || gettext('If this takes longer than a few minutes, please contact us.'))
this.setProgress(null)
this.setSteps(null)
document.getElementById('loadingmodal').showModal()
},
hide: function () {
'use strict'
document.getElementById('loadingmodal').close()
},
setTitle: function (title) {
$('#loadingmodal .modal-card-title').text(title)
},
setStatus: function (statusText) {
$('#loadingmodal p.status').text(statusText)
},
setText: function (text) {
if (text)
$('#loadingmodal .modal-card-description').text(text).show()
else
$('#loadingmodal .modal-card-description').hide()
},
setProgress: function (percentage) {
if (typeof percentage === 'number') {
$('#loadingmodal .progress').show()
$('#loadingmodal .progress .progress-bar').css('width', percentage + '%')
} else {
$('#loadingmodal .progress').hide()
}
},
setSteps: function (steps) {
let $steps = $('#loadingmodal .steps')
if (steps) {
$steps.html('').show()
for (let step of steps) {
$steps.append(
$('<span>').addClass('fa fa-fw')
.toggleClass('fa-check text-success', step.done)
.toggleClass('fa-cog fa-spin text-muted', !step.done)
).append(
$('<span>').text(step.label)
).append(
$('<br>')
)
}
} else {
$steps.hide()
}
}
}
var ajaxErrDialog = {
show: function (c) {
"use strict";
$("#ajaxerr").html(c);
$("#ajaxerr .links").html("<a class='btn btn-default ajaxerr-close'>"
+ gettext("Close message") + "</a>");
$("body").addClass("ajaxerr has-modal-dialog");
$("#ajaxerr").prop("hidden", false);
},
hide: function () {
"use strict";
$("body").removeClass("ajaxerr has-modal-dialog");
$("#ajaxerr").prop("hidden", true);
},
};
show: function (c) {
'use strict'
$('#ajaxerr').html(c)
$('#ajaxerr .links').html('<a class=\'btn btn-default ajaxerr-close\'>'
+ gettext('Close message') + '</a>')
$('body').addClass('ajaxerr has-modal-dialog')
$('#ajaxerr').prop('hidden', false)
},
hide: function () {
'use strict'
$('body').removeClass('ajaxerr has-modal-dialog')
$('#ajaxerr').prop('hidden', true)
},
}
@@ -1,7 +1,7 @@
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('a[href^="mailto:"]').forEach(function(link) {
// Replace [at] with @ and the [dot] with . in both the href and the displayed text (if needed)
link.href = link.href.replace('[at]', '@').replace('[dot]', '.');
link.textContent = link.textContent.replace('[at]', '@').replace('[dot]', '.');
});
});
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('a[href^="mailto:"]').forEach(function (link) {
// Replace [at] with @ and the [dot] with . in both the href and the displayed text (if needed)
link.href = link.href.replace('[at]', '@').replaceAll('[dot]', '.')
link.textContent = link.textContent.replace('[at]', '@').replaceAll('[dot]', '.')
})
})
+137 -140
View File
@@ -1,152 +1,149 @@
/*global $ */
setup_collapsible_details = function (el) {
el.find('.sneak-peek-trigger').each(function () {
let trigger = this
let button = this.querySelector('button')
let content = document.getElementById(button.getAttribute('aria-controls'))
if (content.scrollHeight < 200) {
trigger.remove()
content.classList.remove('sneak-peek-content')
return
}
content.setAttribute('aria-hidden', 'true')
content.setAttribute('inert', true)
button.setAttribute('aria-expanded', 'false')
button.addEventListener('click', function (e) {
button.setAttribute('aria-expanded', 'true')
content.setAttribute('aria-hidden', 'false')
content.removeAttribute('inert')
el.find('.sneak-peek-trigger').each(function() {
var trigger = this;
var button = this.querySelector('button');
var content = document.getElementById(button.getAttribute('aria-controls'));
if (content.scrollHeight < 200) {
trigger.remove();
content.classList.remove('sneak-peek-content');
return;
}
content.setAttribute('aria-hidden', 'true');
content.setAttribute('inert', true);
button.setAttribute('aria-expanded', 'false');
button.addEventListener('click', function (e) {
button.setAttribute('aria-expanded', 'true');
content.setAttribute('aria-hidden', 'false');
content.removeAttribute('inert');
content.addEventListener('transitionend', function () {
content.classList.remove('sneak-peek-content')
content.style.removeProperty('height')
// we need to keep the trigger/button in the DOM to not irritate screenreaders toggling visibility
trigger.classList.add('sr-only')
}, { once: true })
content.style.height = content.scrollHeight + 'px'
content.addEventListener('transitionend', function() {
content.classList.remove('sneak-peek-content');
content.style.removeProperty('height');
// we need to keep the trigger/button in the DOM to not irritate screenreaders toggling visibility
trigger.classList.add('sr-only');
}, {once: true});
content.style.height = content.scrollHeight + 'px';
button.addEventListener('click', function (e) {
// this will be called by screenreader users if they kept focus on the button after expanding
// we need to keep the trigger/button in the DOM to not irritate screenreaders toggling visibility
let expanded = button.getAttribute('aria-expanded') == 'true'
button.setAttribute('aria-expanded', !expanded)
content.setAttribute('aria-hidden', expanded)
})
button.addEventListener('blur', function (e) {
// if content is visible and the user leaves the button, we can safely remove the trigger/button
if (button.getAttribute('aria-expanded') == 'true') {
trigger.remove()
}
})
}, { once: true })
button.addEventListener('click', function (e) {
// this will be called by screenreader users if they kept focus on the button after expanding
// we need to keep the trigger/button in the DOM to not irritate screenreaders toggling visibility
var expanded = button.getAttribute('aria-expanded') == 'true';
button.setAttribute('aria-expanded', !expanded);
content.setAttribute('aria-hidden', expanded);
});
button.addEventListener('blur', function (e) {
// if content is visible and the user leaves the button, we can safely remove the trigger/button
if (button.getAttribute('aria-expanded') == 'true') {
trigger.remove();
}
});
}, { once: true });
let container = this.closest('details.sneak-peek-container')
if (container) {
function removeSneekPeakWhenClosed (e) {
if (e.newState == 'closed') {
container.removeEventListener('toggle', removeSneekPeakWhenClosed)
trigger.remove()
content.removeAttribute('aria-hidden')
content.removeAttribute('inert')
content.classList.remove('sneak-peek-content')
}
}
container.addEventListener('toggle', removeSneekPeakWhenClosed)
}
})
var container = this.closest('details.sneak-peek-container');
if (container) {
function removeSneekPeakWhenClosed(e) {
if (e.newState == "closed") {
container.removeEventListener("toggle", removeSneekPeakWhenClosed);
trigger.remove();
content.removeAttribute('aria-hidden');
content.removeAttribute('inert');
content.classList.remove('sneak-peek-content');
}
}
container.addEventListener("toggle", removeSneekPeakWhenClosed);
}
});
let isOpera = Object.prototype.toString.call(window.opera) == '[object Opera]'
el.find('details summary').click(function (e) {
if (this.tagName !== 'A' && $(e.target).closest('a').length > 0) {
return true
}
let $details = $(this).closest('details')
let isOpen = $details.prop('open')
let $detailsNotSummary = $details.children(':not(summary)')
if ($detailsNotSummary.is(':animated')) {
e.preventDefault()
return false
}
if (isOpen) {
$details.removeClass('details-open')
$detailsNotSummary.stop().show().slideUp(500, function () {
$details.prop('open', false)
})
} else {
$detailsNotSummary.stop().hide()
$details.prop('open', true)
$details.addClass('details-open')
$detailsNotSummary.slideDown()
}
e.preventDefault()
return false
}).keyup(function (event) {
if (32 == event.keyCode || (13 == event.keyCode && !isOpera)) {
// Space or Enter is pressed — trigger the `click` event on the `summary` element
// Opera already seems to trigger the `click` event when Enter is pressed
event.preventDefault()
$(this).click()
}
})
var isOpera = Object.prototype.toString.call(window.opera) == '[object Opera]';
el.find("details summary").click(function (e) {
if (this.tagName !== "A" && $(e.target).closest("a").length > 0) {
return true;
}
var $details = $(this).closest("details");
var isOpen = $details.prop("open");
var $detailsNotSummary = $details.children(':not(summary)');
if ($detailsNotSummary.is(':animated')) {
e.preventDefault();
return false;
}
if (isOpen) {
$details.removeClass("details-open");
$detailsNotSummary.stop().show().slideUp(500, function () {
$details.prop("open", false);
});
} else {
$detailsNotSummary.stop().hide();
$details.prop("open", true);
$details.addClass("details-open");
$detailsNotSummary.slideDown();
}
e.preventDefault();
return false;
}).keyup(function (event) {
if (32 == event.keyCode || (13 == event.keyCode && !isOpera)) {
// Space or Enter is pressed — trigger the `click` event on the `summary` element
// Opera already seems to trigger the `click` event when Enter is pressed
event.preventDefault();
$(this).click();
}
});
$('details').each(function () {
let $details = $(this),
$detailsSummary = $('summary', $details).first(),
$detailsNotSummary = $details.children(':not(summary)')
$details.prop('open', typeof $details.attr('open') == 'string')
if (!$details.prop('open')) {
if ($details.find('.has-error, .alert-danger').length) {
$details.addClass('details-open')
$details.prop('open', true)
} else {
$detailsNotSummary.hide()
}
} else {
$details.addClass('details-open')
}
$detailsSummary.attr({
role: 'button',
'aria-controls': $details.attr('id')
}).prop('tabIndex', 0).bind('selectstart dragstart mousedown', function () {
return false
})
})
$('details').each(function () {
var $details = $(this),
$detailsSummary = $('summary', $details).first(),
$detailsNotSummary = $details.children(':not(summary)');
$details.prop('open', typeof $details.attr('open') == 'string');
if (!$details.prop('open')) {
if ($details.find(".has-error, .alert-danger").length) {
$details.addClass("details-open");
$details.prop('open', true);
} else {
$detailsNotSummary.hide();
}
} else {
$details.addClass("details-open");
}
$detailsSummary.attr({
'role': 'button',
'aria-controls': $details.attr('id')
}).prop('tabIndex', 0).bind('selectstart dragstart mousedown', function () {
return false;
});
});
el.find('article button[data-toggle=variations]').click(function (e) {
let $button = $(this)
let $details = $button.closest('article')
let $detailsNotSummary = $button.attr('aria-controls') ? $('#' + $button.attr('aria-controls')) : $('.variations', $details)
let isOpen = !$detailsNotSummary.prop('hidden')
if ($detailsNotSummary.is(':animated')) {
e.preventDefault()
return false
}
el.find("article button[data-toggle=variations]").click(function (e) {
var $button = $(this);
var $details = $button.closest("article");
var $detailsNotSummary = $button.attr("aria-controls") ? $('#' + $button.attr("aria-controls")) : $(".variations", $details);
var isOpen = !$detailsNotSummary.prop("hidden");
if ($detailsNotSummary.is(':animated')) {
e.preventDefault();
return false;
}
let altLabel = $button.attr('data-label-alt')
$button.attr('data-label-alt', $button.text().trim())
$button.find('span').text(altLabel)
$button.attr('aria-expanded', !isOpen)
var altLabel = $button.attr("data-label-alt");
$button.attr("data-label-alt", $button.text().trim());
$button.find("span").text(altLabel);
$button.attr("aria-expanded", !isOpen);
if (isOpen) {
$details.removeClass("details-open");
$detailsNotSummary.stop().show().slideUp(500, function () {
$detailsNotSummary.prop("hidden", true);
});
} else {
$detailsNotSummary.prop("hidden", false).stop().hide();
$details.addClass("details-open");
$detailsNotSummary.slideDown();
}
e.preventDefault();
return false;
});
el.find(".variations-collapsed").prop("hidden", true);
};
if (isOpen) {
$details.removeClass('details-open')
$detailsNotSummary.stop().show().slideUp(500, function () {
$detailsNotSummary.prop('hidden', true)
})
} else {
$detailsNotSummary.prop('hidden', false).stop().hide()
$details.addClass('details-open')
$detailsNotSummary.slideDown()
}
e.preventDefault()
return false
})
el.find('.variations-collapsed').prop('hidden', true)
}
$(function () {
"use strict";
'use strict'
setup_collapsible_details($("body"));
});
setup_collapsible_details($('body'))
})

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