Compare commits

..
Author SHA1 Message Date
Mira Weller bb5ba65a28 Allow disabling hybrid plugin for all events at once only via form 2026-09-25 18:39:56 +02:00
Mira Weller e518b3f06c Add filter_restricted / allow_restricted params 2026-09-25 18:39:56 +02:00
Mira Weller 88a0482838 Move get_plugins, set_active_plugins, enable_plugin and disable_plugin into PluginsMixin 2026-09-25 17:46:21 +02:00
Mira Weller 7cfa454c2b Fix: plugin.uninstalled() callback runs only if plugin is disabled via control panel (disable_plugin), not via API (set_active_plugins) 2026-09-25 17:39:57 +02:00
Mira Weller 576b55a995 Add available_plugins API 2026-09-25 17:30:04 +02:00
Mira Weller 313beb1122 Clean up get_all_plugins / get_available_plugins calls 2026-09-25 16:47:35 +02:00
Mira Weller a89ebddbc4 Clean up get_all_plugins / get_available_plugins calls 2026-09-25 16:43:46 +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
100 changed files with 3234 additions and 3218 deletions
-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.
+8 -8
View File
@@ -110,7 +110,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"all_sales_channels": false,
@@ -199,7 +199,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"valid_keys": {
@@ -262,7 +262,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -299,7 +299,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -364,7 +364,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -401,7 +401,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -438,7 +438,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
]
}
@@ -475,7 +475,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
],
"all_sales_channels": true,
+1
View File
@@ -12,6 +12,7 @@ at :ref:`plugin-docs`.
organizers
events
subevents
event_meta_properties
taxrules
categories
items
+1
View File
@@ -116,6 +116,7 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1
:query string code: Only show the voucher with the given voucher code.
:query string search: Only show the voucher with the given query found in the code, tag, or comment.
:query integer max_usages: Only show vouchers with the given maximal number of usages.
:query integer redeemed: Only show vouchers with the given number of redemptions. Note that this doesn't tell you if
the voucher can still be redeemed, as this also depends on ``max_usages``. See the
+24 -10
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": {
+7 -7
View File
@@ -33,14 +33,14 @@ dependencies = [
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=50.0.0",
"cryptography>=50.0.1",
"css-inline==0.21.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*,>=5.2.17",
"django-bootstrap3==26.2",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-countries==9.1.*",
"django-filter==26.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.7",
@@ -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.11.*",
"pypdf==6.19.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
@@ -94,7 +94,7 @@ dependencies = [
"redis==7.4.*",
"reportlab==5.0.*",
"requests==2.34.*",
"sentry-sdk==2.68.*",
"sentry-sdk==2.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/*
+3 -11
View File
@@ -268,22 +268,14 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
return {'seat_category_mapping': result}
def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module: p for p in get_all_plugins(event=self.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
current_plugins = self.instance.get_plugins() if self.instance and self.instance.pk else []
settings_holder = self.instance if self.instance and self.instance.pk else self.context['organizer']
obj = self.instance if self.instance and self.instance.pk else self.context['organizer']
plugins_available = obj.get_available_plugins(filter_restricted=True)
allowed_levels = (PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
for plugin in value.get('plugins'):
if plugin not in plugins_available:
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'restricted', False):
if plugin not in settings_holder.settings.allowed_restricted_plugins:
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
raise ValidationError(_('Unknown or restricted plugin: \'{name}\'.').format(name=plugin))
level = getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT)
if level not in allowed_levels:
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
+3 -3
View File
@@ -114,11 +114,11 @@ class UploadedFileField(serializers.Field):
class PluginsField(serializers.Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
from pretix.base.plugins import iter_all_plugins
active_plugins = set(obj.get_plugins())
return sorted([
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in active_plugins
p.module for p in iter_all_plugins(only_visible=True)
if p.module in active_plugins
])
def to_internal_value(self, data):
+92 -14
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 (
@@ -78,21 +80,12 @@ class OrganizerSerializer(I18nAwareModelSerializer):
fields = ('name', 'slug', 'public_url', 'plugins')
def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module: p for p in get_all_plugins(organizer=self.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
settings_holder = self.instance
plugins_available = self.instance.get_available_plugins(filter_restricted=True)
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
for plugin in value.get('plugins'):
if plugin not in plugins_available:
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'restricted', False):
if plugin not in settings_holder.settings.allowed_restricted_plugins:
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
raise ValidationError(_('Unknown or restricted plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT) not in allowed_levels:
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
@@ -640,3 +633,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)
+17
View File
@@ -342,6 +342,23 @@ class EventViewSet(viewsets.ModelViewSet):
raise PermissionDenied('The event could not be deleted as some constraints (e.g. data created by plug-ins) '
'do not allow it.')
@action(detail=True, methods=['GET'])
def available_plugins(self, *args, **kwargs):
from pretix.base.plugins import get_all_plugins, PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID
plugins_available = [
{
"plugin": p.module,
"name": p.name,
"level": getattr(p, "level", PLUGIN_LEVEL_EVENT),
"restricted": ("allowed" if p.module in self.request.event.settings.allowed_restricted_plugins else "restricted") if getattr(p, "restricted", False) else "no",
}
for p in get_all_plugins(event=self.request.event, only_visible=True)
if getattr(p, "level", PLUGIN_LEVEL_EVENT) in (PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT)
]
return Response({
'results': plugins_available,
})
class CloneEventViewSet(viewsets.ModelViewSet):
serializer_class = CloneEventSerializer
+68 -11
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,
@@ -91,8 +92,6 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
@transaction.atomic()
def perform_update(self, serializer):
from pretix.base.plugins import get_all_plugins
original_data = self.get_serializer(instance=serializer.instance).data
current_plugins_value = serializer.instance.get_plugins()
@@ -110,11 +109,7 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled)
plugins_available = {
p.module: p
for p in get_all_plugins(organizer=serializer.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
plugins_available = serializer.instance.get_available_plugins()
qs = []
for module in disabled:
pluginmeta = plugins_available[module]
@@ -150,6 +145,22 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
data={'plugin': module}
)
@action(detail=True, methods=['GET'])
def available_plugins(self, *args, **kwargs):
from pretix.base.plugins import get_all_plugins
plugins_available = [
{
"plugin": p.module,
"name": p.name,
"level": getattr(p, "level", PLUGIN_LEVEL_EVENT),
"restricted": ("allowed" if p.module in self.request.organizer.settings.allowed_restricted_plugins else "restricted") if getattr(p, "restricted", False) else "no",
}
for p in get_all_plugins(organizer=self.request.organizer, only_visible=True)
]
return Response({
'results': plugins_available
})
class SeatingPlanViewSet(viewsets.ModelViewSet):
serializer_class = SeatingPlanSerializer
@@ -846,3 +857,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'),
+7 -5
View File
@@ -1202,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()
@@ -1444,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
@@ -1499,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,
),
]
+52
View File
@@ -274,3 +274,55 @@ class LockModel:
field.delete_cached_value(self)
self._state.db = db_instance._state.db
class PluginsMixin:
def get_plugins(self):
"""
Returns the names of the plugins activated for this model as a list.
"""
if self.plugins is None:
return []
return self.plugins.split(",")
def set_active_plugins(self, modules, allow_restricted=frozenset()):
plugins_available = self.get_available_plugins()
plugins_current = set(self.get_plugins())
plugins_new = set(modules)
for module in plugins_new - plugins_current:
if module not in plugins_available:
continue
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
for module in plugins_current - plugins_new:
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()):
"""
Adds a plugin to the list of plugins, calling its ``installed`` hook (if available).
It is the caller's responsibility to save the model object, as well as, in case of enabling
a hybrid organizer-event plugin on an event, to enable it on the organizer, if necessary.
"""
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
"""
Removes a plugin from the list of plugins, calling its ``uninstalled`` hook (if available).
It is the caller's responsibility to save the model object, as well as, in case of disabling
a hybrid organizer-event plugin on an organizer, to remove it from all events.
"""
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
+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
+5 -54
View File
@@ -67,7 +67,7 @@ from django.utils.translation import gettext, gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel
from pretix.base.models.base import LoggedModel, PluginsMixin
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.timemachine import time_machine_now
from pretix.base.validators import EventSlugBanlistValidator
@@ -563,7 +563,7 @@ def default_sales_channels(): # kept for legacy migration
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
class Event(EventMixin, LoggedModel):
class Event(PluginsMixin, EventMixin, LoggedModel):
"""
This model represents an event. An event is anything you can buy
tickets for.
@@ -800,14 +800,6 @@ class Event(EventMixin, LoggedModel):
self.cache.clear()
return obj
def get_plugins(self):
"""
Returns the names of the plugins activated for this event as a list.
"""
if self.plugins is None:
return []
return self.plugins.split(",")
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -1474,51 +1466,10 @@ class Event(EventMixin, LoggedModel):
self.items.all().delete()
self.subevents.all().delete()
def get_available_plugins(self):
from pretix.base.plugins import get_all_plugins
def get_available_plugins(self, filter_restricted=False):
from pretix.base.plugins import get_all_plugins_map, ALLOW_ALL
return {
p.module: p for p in get_all_plugins(event=self)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
def set_active_plugins(self, modules, allow_restricted=frozenset()):
plugins_active = self.get_plugins()
plugins_available = self.get_available_plugins()
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()):
"""
Adds a plugin to the list of plugins, calling its ``installed`` hook (if available).
It is the caller's responsibility to save the event object.
"""
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
"""
Adds a plugin to the list of plugins, calling its ``uninstalled`` hook (if available).
It is the caller's responsibility to save the event object.
"""
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
plugins_available = self.get_available_plugins()
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
return get_all_plugins_map(event=self, only_visible=True, allow_restricted=self.settings.allowed_restricted_plugins if filter_restricted else ALLOW_ALL)
@staticmethod
def clean_has_subevents(event, has_subevents):
+5 -55
View File
@@ -52,7 +52,7 @@ from django_scopes import ScopedManager, scope
from i18nfield.fields import I18nCharField
from i18nfield.strings import LazyI18nString
from pretix.base.models.base import LoggedModel
from pretix.base.models.base import LoggedModel, PluginsMixin
from pretix.base.validators import OrganizerSlugBanlistValidator
from ...helpers.permission_migration import (
@@ -67,7 +67,7 @@ if TYPE_CHECKING:
@settings_hierarkey.add(cache_namespace='organizer')
class Organizer(LoggedModel):
class Organizer(PluginsMixin, LoggedModel):
"""
This model represents an entity organizing events, e.g. a company, institution,
charity, person, …
@@ -166,60 +166,10 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self)
def get_plugins(self):
"""
Returns the names of the plugins activated for this organizer as a list.
"""
if not self.plugins:
return []
return self.plugins.split(",")
def get_available_plugins(self, *, filter_restricted=False):
from pretix.base.plugins import get_all_plugins_map, ALLOW_ALL
def get_available_plugins(self):
from pretix.base.plugins import get_all_plugins
return {
p.module: p for p in get_all_plugins(organizer=self)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
def set_active_plugins(self, modules, allow_restricted=frozenset()):
plugins_active = self.get_plugins()
plugins_available = self.get_available_plugins()
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()):
"""
Adds a plugin to the list of plugins, calling its ``installed`` hook (if available).
It is the caller's responsibility to save the organizer object.
"""
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
"""
Removes a plugin from the list of plugins, calling its ``uninstalled`` hook (if available).
It is the caller's responsibility to save the organizer object and, in case of a hybrid organizer-event plugin,
to remove it from all events.
"""
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
plugins_available = self.get_available_plugins()
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
return get_all_plugins_map(organizer=self, only_visible=True, allow_restricted=self.settings.allowed_restricted_plugins if filter_restricted else ALLOW_ALL)
@property
def timezone(self):
+35 -17
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)
@@ -1297,26 +1308,33 @@ 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]
_merge_with_correct_page_media_box(fg_pdf, page, bg_page)
_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 _merge_with_correct_page_media_box(output: pypdf.PdfWriter, fg_page: pypdf.PageObject, bg_page: pypdf.PageObject):
if bg_page.rotation != 0:
bg_page.transfer_rotation_to_content()
media_box = bg_page.mediabox
"""
Adds fg_page to output, merging bg_page behind it.
If bg_page has a non-zero mergebox/cropbox or is rotated via /Rotate, a transformation is applied to fix this."""
trsf = pypdf.Transformation()
if media_box.bottom != 0:
trsf = trsf.translate(0, -media_box.bottom)
if media_box.left != 0:
trsf = trsf.translate(-media_box.left, 0)
if bg_page.rotation != 0:
trsf = trsf.rotate(-bg_page.rotation)
mb = get_sizebox(bg_page)
pt1 = trsf.apply_on(mb.lower_left)
pt2 = trsf.apply_on(mb.upper_right)
trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1]))
fg_page = output.add_page(fg_page)
fg_page.merge_transformed_page(bg_page, trsf, over=False, expand=False)
+42 -18
View File
@@ -22,7 +22,7 @@
import os
import sys
from enum import Enum
from typing import List
from typing import Iterable, List
import importlib_metadata as metadata
from django.apps import AppConfig, apps
@@ -37,6 +37,10 @@ PLUGIN_LEVEL_ORGANIZER = 'organizer'
PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID = 'event_organizer'
class ALLOW_ALL:
pass
class PluginType(Enum):
"""
Plugin type classification. THIS IS DEPRECATED, DO NOT USE ANY MORE.
@@ -49,7 +53,13 @@ class PluginType(Enum):
EXPORT = 4
def plugin_is_available(meta, event=None, organizer=None):
def plugin_is_available(meta, event=None, organizer=None, only_visible=False, allow_restricted: type[ALLOW_ALL] | List[str]=ALLOW_ALL):
if only_visible and (meta.name.startswith('.') or not getattr(meta, 'visible', True)):
return False
if allow_restricted is not ALLOW_ALL and getattr(meta, 'restricted', False) and meta.module not in allow_restricted:
return False
if not hasattr(meta.app, 'is_available'):
return True
@@ -76,30 +86,44 @@ def plugin_is_available(meta, event=None, organizer=None):
return True
def get_all_plugins(*, event=None, organizer=None) -> List[type]:
def get_plugin_meta_from_app_config(app):
if not hasattr(app, 'PretixPluginMeta'):
return None
meta = app.PretixPluginMeta
meta.module = app.name
meta.app = app
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
return None
return meta
def iter_all_plugins(*, event=None, organizer=None, only_visible=False, allow_restricted: type[ALLOW_ALL] | List[str]=ALLOW_ALL) -> Iterable[type]:
assert not event or not organizer
for app in apps.get_app_configs():
if meta := get_plugin_meta_from_app_config(app):
if plugin_is_available(meta, event, organizer, only_visible, allow_restricted):
yield meta
def get_all_plugins(*, event=None, organizer=None, only_visible=False, allow_restricted: type[ALLOW_ALL] | List[str]=ALLOW_ALL) -> List[type]:
"""
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
Of the `event` and `organizer` params, at most one may be filled, and they are only used for
calling `is_available`, not for filtering by plugin level.
"""
assert not event or not organizer
plugins = []
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
meta = app.PretixPluginMeta
meta.module = app.name
meta.app = app
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
if not plugin_is_available(meta, event, organizer):
continue
plugins.append(meta)
return sorted(
plugins,
iter_all_plugins(event=event, organizer=organizer, only_visible=only_visible, allow_restricted=allow_restricted),
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
)
def get_all_plugins_map(*, event=None, organizer=None, only_visible=False, allow_restricted: type[ALLOW_ALL] | List[str]=ALLOW_ALL) -> dict[str, type]:
return {
p.module: p for p in iter_all_plugins(event=event, organizer=organizer, only_visible=only_visible, allow_restricted=allow_restricted)
}
class PluginConfigMeta(type):
def __getattribute__(cls, item):
if item == "default" and cls is PluginConfig:
+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
+11 -6
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
@@ -1635,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):
+12 -1
View File
@@ -1394,16 +1394,27 @@ class SalesChannelForm(I18nModelForm):
class OrganizerPluginEventsForm(forms.Form):
active_on_organizer = forms.BooleanField(
label=_("Active on organizer-level"),
help_text=_("Enables or disables the organizer-wide features of this plugin."),
required=False,
)
events = SafeEventMultipleChoiceField(
queryset=Event.objects.none(),
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
'data-checkbox-dependency': '#id_active_on_organizer'
}),
label=_("Events with active plugin"),
required=False,
)
def __init__(self, *args, **kwargs):
def __init__(self, *args, hybrid, **kwargs):
events = kwargs.pop('events')
super().__init__(*args, **kwargs)
if not hybrid:
del self.fields['active_on_organizer']
self.fields['events'].widget = forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
})
self.fields['events'].queryset = events
+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 %}
@@ -43,7 +43,7 @@
<legend>{{ catlabel }}</legend>
<div class="plugin-list">
{% for plugin, is_active, settings_links, navigation_links, events_counter in plist %}
<div class="plugin-container {% if plugin.featured %}featured-plugin{% endif %}" id="plugin_{{ plugin.module }}" data-plugin-module="{{ plugin.module }}" data-plugin-name="{{ plugin.name }}">
<div class="plugin-container {% if plugin.featured %}featured-plugin{% endif %}" id="plugin_{{ plugin.module }}" data-plugin-module="{{ plugin.module }}" data-plugin-name="{{ plugin.name }}" data-plugin-level="{{ plugin.level }}">
{% if plugin.featured %}
<div class="panel panel-default">
<div class="panel-body">
@@ -89,8 +89,8 @@
Active ({{ count }} events)
{% endblocktrans %}
</span>
{% elif level == "event_organizer" %}
<span class="label label-info" data-is-active>
{% elif plugin.level == "event_organizer" %}
<span class="label label-success" data-is-active>
<span class="fa fa-check" aria-hidden="true"></span>
{% blocktrans trimmed count count=0 %}
Active ({{ count }} event)
@@ -143,13 +143,14 @@
</ul>
</div>
{% endif %}
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
{% if plugin.level == "event_organizer" %}
<a class="btn btn-default {% if plugin.featured %} btn-lg{% endif %}"
href="{% url "control:organizer.settings.plugin-events" organizer=request.organizer.slug plugin=plugin.module %}">
{% trans "Manage events" %}
{% trans "Manage plugin and events ..." %}
</a>
{% else %}
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
{% endif %}
</div>
{% else %}
@@ -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'),
+41 -6
View File
@@ -35,6 +35,7 @@
import base64
import json
import logging
import math
import time
from urllib.parse import quote, urljoin, urlparse
@@ -50,11 +51,12 @@ from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from django_otp import match_token
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from webauthn.helpers import generate_challenge
@@ -463,6 +465,7 @@ class Login2FAView(TemplateView):
token = request.POST.get('token', '').strip().replace(' ', '')
valid = False
retry_after = None
if 'webauthn_challenge' in self.request.session and token.startswith('{'):
challenge = self.request.session['webauthn_challenge']
@@ -518,12 +521,28 @@ class Login2FAView(TemplateView):
valid = True
break
else:
valid = match_token(self.user, token)
if isinstance(valid, StaticDevice):
with transaction.atomic():
for device in devices_for_user(self.user, for_verify=True):
if isinstance(device, StaticDevice) and len(token) < 12:
# If we enter a wrong TOTP token (which is 6 characters), do not even try if it is a valid
# emergency token, which will only "lock up" the StaticDevice due to the throttling plugin
# and just locks people out without security gain.
continue
if device.verify_token(token):
valid = True
break
elif hasattr(device, 'verify_is_allowed'):
verify_allowed, reason_dict = device.verify_is_allowed()
if not verify_allowed:
if not retry_after or reason_dict['locked_until'] > retry_after:
retry_after = reason_dict['locked_until']
else:
device = None
if isinstance(device, StaticDevice):
self.user.send_security_notice([
_("A recovery code for two-factor authentification was used to log in.")
])
if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
pretix_successful_logins.inc(1)
@@ -536,7 +555,23 @@ class Login2FAView(TemplateView):
return redirect('control:index')
else:
pretix_failed_logins.inc(1, reason="2fa")
messages.error(request, _('Invalid code, please try again.'))
msg = _('Invalid code, please try again.')
if retry_after:
seconds = (retry_after - now()).total_seconds()
minutes = seconds / 60
if minutes >= 1:
msg = ngettext(
'Invalid code. Please try again after waiting {value} minute.',
'Invalid code. Please try again after waiting {value} minutes.',
minutes,
).format(value=math.ceil(minutes))
elif seconds >= 1:
msg = ngettext(
'Invalid code. Please try again after waiting {value} second.',
'Invalid code. Please try again after waiting {value} seconds.',
seconds,
).format(value=math.ceil(seconds))
messages.error(request, msg)
return redirect('control:auth.login.2fa')
def get_context_data(self, **kwargs):
+3 -16
View File
@@ -363,12 +363,6 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
def get_object(self, queryset=None) -> Event:
return self.request.event
def available_plugins(self, event):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(event=event) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def prepare_links(self, pluginmeta, key):
links = getattr(pluginmeta, key, [])
try:
@@ -391,14 +385,13 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
return []
def get_context_data(self, *args, **kwargs) -> dict:
from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER
from pretix.base.plugins import iter_all_plugins, CATEGORY_LABELS, CATEGORY_ORDER
context = super().get_context_data(*args, **kwargs)
plugins = list(self.available_plugins(self.object))
plugins_grouped = groupby(
sorted(
plugins,
iter_all_plugins(event=self.object, only_visible=True),
key=lambda p: (
str(getattr(p, 'category', _('Other'))),
(0 if getattr(p, 'featured', False) else 1),
@@ -439,9 +432,7 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
def post(self, request, *args, **kwargs):
self.object = self.get_object()
plugins_available = {
p.module: p for p in self.available_plugins(self.object)
}
plugins_available = self.object.get_available_plugins(filter_restricted=True)
plugin_enabled = None
with transaction.atomic():
@@ -451,10 +442,6 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
module = key.split(":")[1]
if value == "enable" and module in plugins_available:
pluginmeta = plugins_available[module]
if getattr(pluginmeta, 'restricted', False):
if module not in request.event.settings.allowed_restricted_plugins:
continue
if getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT) not in (PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID):
continue
+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():
+34 -56
View File
@@ -596,13 +596,6 @@ class OrganizerCreate(CreateView):
})
def available_plugins(organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Organizer
context_object_name = 'organizer'
@@ -634,10 +627,9 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
return []
def get_context_data(self, *args, **kwargs) -> dict:
from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER
from pretix.base.plugins import iter_all_plugins, CATEGORY_LABELS, CATEGORY_ORDER
context = super().get_context_data(*args, **kwargs)
plugins = list(available_plugins(self.object))
active_counter = Counter()
events_total = 0
@@ -647,7 +639,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
active_counter[p] += 1
plugins_grouped = groupby(
sorted(
plugins,
iter_all_plugins(organizer=self.object, only_visible=True),
key=lambda p: (
str(getattr(p, 'category', _('Other'))),
(0 if getattr(p, 'featured', False) else 1),
@@ -684,9 +676,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def post(self, request, *args, **kwargs):
self.object = self.get_object()
plugins_available = {
p.module: p for p in available_plugins(self.object)
}
plugins_available = self.object.get_available_plugins(filter_restricted=True)
choose_events_next = False
with transaction.atomic():
for key, value in request.POST.items():
@@ -694,10 +684,6 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
module = key.split(":")[1]
if value == "enable" and module in plugins_available:
pluginmeta = plugins_available[module]
if getattr(pluginmeta, 'restricted', False):
if module not in request.organizer.settings.allowed_restricted_plugins:
continue
level = getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT)
if level not in (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID):
continue
@@ -729,27 +715,9 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
elif value == "disable" and module in plugins_available:
pluginmeta = plugins_available[module]
level = getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT)
if level not in (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID):
if level != PLUGIN_LEVEL_ORGANIZER:
continue
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
events_to_disable = set(self.request.organizer.events.filter(
plugins__regex='(^|,)' + module + '(,|$)'
).values_list("pk", flat=True))
logentries_to_save = []
events_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_disable):
logentries_to_save.append(
e.log_action('pretix.event.plugins.disabled', user=self.request.user,
data={'plugin': module}, save=False)
)
e.disable_plugin(module)
events_to_save.append(e)
Event.objects.bulk_update(events_to_save, fields=["plugins"])
LogEntry.objects.bulk_create(logentries_to_save)
self.object.log_action('pretix.organizer.plugins.disabled', user=self.request.user,
data={'plugin': module})
self.object.disable_plugin(module)
@@ -778,10 +746,12 @@ 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["hybrid"] = self.plugin_level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID
kwargs["initial"] = {
"active_on_organizer": self.plugin.module in self.request.organizer.get_plugins(),
"events": self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)')
}
return kwargs
@@ -793,20 +763,12 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
)
def dispatch(self, request, *args, **kwargs):
try:
self.plugin = next(p for p in available_plugins(self.request.organizer) if p.module == kwargs["plugin"])
except StopIteration:
self.plugin = self.request.organizer.get_available_plugins(filter_restricted=True).get(kwargs["plugin"])
self.plugin_level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT)
if not self.plugin:
raise Http404(_("Unknown plugin."))
level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_ORGANIZER:
if self.plugin_level == PLUGIN_LEVEL_ORGANIZER:
raise Http404(_("This plugin can only be enabled for the entire organizer account."))
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and self.plugin.module not in self.request.organizer.get_plugins():
raise Http404(_("This plugin is currently not active on the organizer account."))
if getattr(self.plugin, 'restricted', False):
if self.plugin.module not in request.organizer.settings.allowed_restricted_plugins:
raise Http404(_("This plugin is currently not allowed for this organizer account."))
return super().dispatch(request, *args, **kwargs)
def get_success_url(self) -> str:
@@ -816,27 +778,43 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
@transaction.atomic()
def form_valid(self, form):
organizer = self.request.organizer
enabled_events_before = set(
self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)').values_list("pk", flat=True)
organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)').values_list("pk", flat=True)
)
enabled_events_now = {e.pk for e in form.cleaned_data["events"]}
if self.plugin_level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
if not form.cleaned_data["active_on_organizer"]:
enabled_events_now = set()
if self.plugin.module in organizer.get_plugins():
organizer.log_action('pretix.organizer.plugins.disabled', user=self.request.user,
data={'plugin': self.plugin.module})
organizer.disable_plugin(self.plugin.module)
organizer.save()
else:
if self.plugin.module not in organizer.get_plugins():
organizer.log_action('pretix.organizer.plugins.enabled', user=self.request.user,
data={'plugin': self.plugin.module})
organizer.enable_plugin(self.plugin.module)
organizer.save()
events_to_enable = enabled_events_now - enabled_events_before
events_to_disable = enabled_events_before - enabled_events_now
events_to_save = []
logentries_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_enable):
if not plugin_is_available(self.plugin, organizer=self.request.organizer, event=e):
for e in organizer.events.filter(pk__in=events_to_enable):
if not plugin_is_available(self.plugin, organizer=organizer, event=e):
messages.warning(self.request, _("This plugin cannot be activated for event {}.").format(e.name))
continue
logentries_to_save.append(
e.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False)
)
e.enable_plugin(self.plugin.module, allow_restricted=self.request.organizer.settings.allowed_restricted_plugins)
e.enable_plugin(self.plugin.module, allow_restricted=organizer.settings.allowed_restricted_plugins)
events_to_save.append(e)
for e in self.request.organizer.events.filter(pk__in=events_to_disable):
for e in organizer.events.filter(pk__in=events_to_disable):
logentries_to_save.append(
e.log_action('pretix.event.plugins.disabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False)
)
+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"
+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"
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -372,10 +372,7 @@ def _render_badges(event: Event, positions: List[OrderPosition], opt: dict) -> T
fg_pdf.append(buffer)
new_num_pages = len(fg_pdf.pages)
for i in range(new_num_pages - num_pages):
bg_page = renderer.bg_pdf.pages[i]
if bg_page.rotation != 0:
bg_page.transfer_rotation_to_content()
bg_pdf.add_page(bg_page)
bg_pdf.add_page(renderer.bg_pdf.pages[i])
num_pages = new_num_pages
return fg_pdf, bg_pdf, num_pages
-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')
+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.
+2 -2
View File
@@ -69,7 +69,7 @@ from pretix.base.models import (
from pretix.base.payment import (
BasePaymentProvider, PaymentException, WalletQueries,
)
from pretix.base.plugins import get_all_plugins
from pretix.base.plugins import get_all_plugins_map
from pretix.base.settings import SettingsSandbox
from pretix.base.views.redirect import safelink
from pretix.helpers import OF_SELF
@@ -234,7 +234,7 @@ class StripeSettingsHolder(BasePaymentProvider):
@property
def settings_form_fields(self):
if 'pretix_resellers' in [p.module for p in get_all_plugins()]:
if 'pretix_resellers' in get_all_plugins_map():
moto_settings = [
('reseller_moto',
forms.BooleanField(
+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
@@ -139,9 +139,6 @@
{% if event_logo and event_logo_show_title %}
<h2 class="content-header">
{{ event.name }}
{% if request.event.settings.show_dates_on_frontpage %}
<small>{{ event.get_date_range_display_as_html }}</small>
{% endif %}
</h2>
{% endif %}
{% if frontpage_text %}
@@ -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>
@@ -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]', '.')
link.href = link.href.replace('[at]', '@').replaceAll('[dot]', '.')
link.textContent = link.textContent.replace('[at]', '@').replaceAll('[dot]', '.')
})
})
@@ -401,6 +401,9 @@ let form_handlers = function (el) {
if (tagName !== 'div' && tagName !== 'button') {
$toggling = dependent.closest('.form-group')
}
if ($toggling.find(".has-error").length > 0) {
enabled = true // Don't hide error message that makes form unsubmittable
}
if (ev) {
if (enabled) {
$toggling.stop().slideDown()
@@ -233,7 +233,7 @@ let form_handlers = function (el) {
function setup_basics (el) {
el.find('form').attr('novalidate', true).on('submit', function (e) {
if (e.originalEvent.submitter.formNoValidate) return;
if (e.originalEvent && e.originalEvent.submitter && e.originalEvent.submitter.formNoValidate) return;
if (!this.checkValidity()) {
let input = this.querySelector(':invalid:not(fieldset)');
(input.labels[0] || input).scrollIntoView()
@@ -114,7 +114,7 @@ td(
)
.pretix-widget-event-calendar-day(v-if="day", :aria-label="dateStr") {{ daynum }}
.pretix-widget-event-calendar-events(v-if="day")
EventCalendarEvent(v-for="e in day.events", :key="e.event_url", :event="e")
EventCalendarEvent(v-for="e in day.events", :key="e.event_url+'-'+(e.subevent||'')", :event="e")
</template>
<style lang="sass">
</style>
+9
View File
@@ -61,6 +61,15 @@ def meta_prop(organizer):
return organizer.meta_properties.create(name="type", default="Concert")
@pytest.fixture
@scopes_disabled()
def meta_prop_choices(organizer):
return organizer.meta_properties.create(name="department", default="A", choices=[
{"key": "A", "label": {"en": "Group A"}},
{"key": "B", "label": {"en": "Group B"}},
])
@pytest.fixture
@scopes_disabled()
def event(organizer, meta_prop):
+229
View File
@@ -0,0 +1,229 @@
#
# 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 pytest
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
@pytest.fixture
def event_meta_property(organizer):
return organizer.meta_properties.create(
name="Color",
default="Red",
required=False,
choices=[
{
"key": "Red",
"label": LazyI18nString("Rot"),
"DELETE": False,
"ORDER": 1,
}
],
)
TEST_TYPE_RES = {
"name": "Color",
"default": "Red",
"required": False,
"choices": [{"key": "Red", "label": {"en": "Rot"}}],
'filter_allowed': True,
'filter_public': False,
'protected': False,
'public_label': None,
}
@pytest.mark.django_db
def test_meta_property_list(token_client, organizer, event_meta_property):
res = dict(TEST_TYPE_RES)
resp = token_client.get('/api/v1/organizers/{}/event_meta_properties/'.format(organizer.slug))
assert resp.status_code == 200
event_meta_property.refresh_from_db()
res["id"] = event_meta_property.pk
assert res in resp.data['results']
assert len(resp.data['results']) == 1
@pytest.mark.django_db
def test_meta_property_detail(token_client, organizer, event_meta_property):
res = TEST_TYPE_RES
resp = token_client.get('/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk))
assert resp.status_code == 200
event_meta_property.refresh_from_db()
res["id"] = event_meta_property.pk
assert res == resp.data
@pytest.mark.django_db
def test_meta_property_create(token_client, organizer):
url = '/api/v1/organizers/{}/event_meta_properties/'.format(organizer.slug)
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "",
"required": False,
"choices": [
{"key": {"foo": "bar"}},
"blabla",
{"label": "Green"},
{"key": "g", "label": "Green", "foo": "bar"},
],
}
)
assert resp.status_code == 400
assert str(resp.data["choices"][0][0]) == "Meta property value options must have a key of type string."
assert str(resp.data["choices"][1][0]) == "Meta property value options must be a dict."
assert str(resp.data["choices"][2][0]) == "Meta property value options must have a key of type string."
assert str(resp.data["choices"][3][0]) == "Meta property value options may only have a key and optionally a label."
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "Red",
"required": False,
"choices": {"key": "r", "label": "Red"},
}
)
assert resp.status_code == 400
assert str(resp.data["choices"][0]) == 'Expected a list of items but got type "dict".'
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "r",
"required": False,
"choices": [
{"key": "r", "label": {"en": "Red"}},
{"key": "r", "label": {"en": "Razzmatazz"}},
],
}
)
assert resp.status_code == 400
assert str(resp.data["choices"][0]) == "The key for each meta property value option must be unique."
choices = [
{"key": "r", "label": "Red"},
{"key": "g", "label": "Green"},
{"key": "b", "label": "Blue"},
]
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "k",
"required": False,
"choices": choices,
}
)
assert resp.status_code == 400
assert str(resp.data["non_field_errors"][0]) == "You cannot set a default value that is not a valid value."
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "r",
"required": False,
"choices": choices,
}
)
assert resp.status_code == 201
with scopes_disabled():
event_meta_property = organizer.meta_properties.get(id=resp.data['id'])
assert event_meta_property.name == "Color"
assert event_meta_property.default == "r"
assert event_meta_property.choices == choices
assert not event_meta_property.required
assert len(organizer.meta_properties.all()) == 1
@pytest.mark.django_db
def test_meta_property_patch(token_client, organizer, event_meta_property):
url = '/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk)
resp = token_client.patch(
url,
format='json',
data={
# existing default is not in choices
"choices": [{'key': 'k', 'label': 'Black'}],
}
)
assert resp.status_code == 400
assert str(resp.data["non_field_errors"][0]) == "You cannot set a default value that is not a valid value."
resp = token_client.patch(
"/api/v1/organizers/{}/event_meta_properties/{}/"
.format(organizer.slug, event_meta_property.pk),
format="json",
data={
"choices": [
{"key": "r", "label": ["wrong"]},
{"key": "g", "label": {"de": {"de": 123, "en": "wrong"}}}
],
}
)
assert resp.status_code == 400
assert str(resp.data["choices"][0]["label"][0]) == "Invalid data type."
assert str(resp.data["choices"][1]["label"][0]) == "All entries must be strings."
resp = token_client.patch(
url,
format='json',
data={
"choices": [],
}
)
assert resp.status_code == 200
event_meta_property.refresh_from_db()
assert event_meta_property.choices is None
resp = token_client.patch(
url,
format='json',
data={
"required": True,
"choices": None,
}
)
assert resp.status_code == 200
event_meta_property.refresh_from_db()
assert event_meta_property.required
assert event_meta_property.choices is None
@pytest.mark.django_db
def test_meta_property_delete(token_client, organizer, event_meta_property):
resp = token_client.delete(
'/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk),
)
assert resp.status_code == 204
assert len(organizer.meta_properties.all()) == 0
+66 -1
View File
@@ -703,7 +703,7 @@ def test_event_delete_with_clone(token_client, organizer, event, meta_prop):
@pytest.mark.django_db
def test_event_update(token_client, organizer, event, item, meta_prop):
def test_event_update(token_client, team, organizer, event, item, meta_prop, meta_prop_choices):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
@@ -782,6 +782,26 @@ def test_event_update(token_client, organizer, event, item, meta_prop):
property__name=meta_prop.name, value="Workshop"
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop.name: "Workshop",
meta_prop_choices.name: "B"
}
},
format='json'
)
assert resp.status_code == 200
assert resp.data["meta_data"] == {
meta_prop.name: "Workshop",
meta_prop_choices.name: "B"
}
with scopes_disabled():
assert organizer.events.get(slug=resp.data['slug']).meta_values.filter(
property__name=meta_prop_choices.name, value="B"
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
@@ -796,6 +816,51 @@ def test_event_update(token_client, organizer, event, item, meta_prop):
property__name=meta_prop.name
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "invalid"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"meta_data":["Meta data property \'department\' does not allow value \'invalid\'."]}'
meta_prop_choices.protected = True
meta_prop_choices.save()
team.all_organizer_permissions = False
team.limit_organizer_permissions = {}
team.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "A"
}
},
format='json'
)
assert resp.status_code == 200 # silently ignored
assert resp.data["meta_data"] == {}
team.all_organizer_permissions = True
team.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "A"
}
},
format='json'
)
assert resp.status_code == 200
assert resp.data["meta_data"] == {
meta_prop_choices.name: "A"
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
+2
View File
@@ -223,6 +223,8 @@ org_permission_sub_urls = [
('post', 'organizer.customers:write', 'customers/1/anonymize/', 404),
('put', 'organizer.customers:write', 'customers/1/', 404),
('delete', 'organizer.customers:write', 'customers/1/', 404),
('post', 'organizer.settings.general:write', 'event_meta_properties/', 400),
('patch', 'organizer.settings.general:write', 'event_meta_properties/0/', 404),
('get', 'organizer.customers:read', 'memberships/', 200),
('post', 'organizer.customers:write', 'memberships/', 400),
('get', 'organizer.customers:read', 'memberships/1/', 404),
+9
View File
@@ -115,6 +115,15 @@ def test_voucher_list(token_client, organizer, event, voucher, item, quota, sube
)
assert [] == resp.data['results']
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/vouchers/?search={}'.format(organizer.slug, event.slug, voucher.code[:3])
)
assert [res] == resp.data['results']
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/vouchers/?code=RAvRcnbDehWD59oywV5x'.format(organizer.slug, event.slug)
)
assert [] == resp.data['results']
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/vouchers/?max_usages=1'.format(organizer.slug, event.slug)
)
+1 -1
View File
@@ -43,7 +43,7 @@ def event():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
return event
+1 -1
View File
@@ -37,7 +37,7 @@ def event():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
return event
+5 -2
View File
@@ -342,8 +342,11 @@ def test_invoice_shredder(event, order):
s.shred_data()
inv.refresh_from_db()
assert "Acme" not in inv.invoice_to
assert "icket" not in inv.lines.first().description
assert inv.invoice_to == "█"
assert inv.invoice_to_company == "█"
l = inv.lines.first()
assert l.description == "█"
assert l.attendee_name == "█"
assert not inv.file
assert not os.path.exists(fname)
+33 -6
View File
@@ -26,7 +26,7 @@ from django.template import Context, Template
from django.test import RequestFactory
from django.utils import translation
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.money import money_filter, tax_rate_format
TEMPLATE_REPLACE_PAGE = Template(
"{% load urlreplace %}{% url_replace request 'page' 3 %}"
@@ -60,7 +60,9 @@ def test_urlreplace_replace_parameter():
"locale,amount,currency,expected",
[
("en", None, "USD", "$0.00"),
("en", "", "USD", ""),
("en", 1000000, "USD", "$1,000,000.00"),
("en", 2.23, "USD", "$2.23"),
("en", Decimal("1000.00"), "USD", "$1,000.00"),
("de", Decimal("1.23"), "EUR", "1,23" + NBSP + "€"),
("de", Decimal("1000.00"), "EUR", "1.000,00" + NBSP + "€"),
@@ -70,11 +72,14 @@ def test_urlreplace_replace_parameter():
# unknown currency
("de", Decimal("1234.56"), "FOO", "1.234,56" + NBSP + "FOO"),
("de", Decimal("1234.567"), "FOO", "1.234,57" + NBSP + "FOO"),
("de", Decimal("1234.567"), "FOO", "1.234,567" + NBSP + "FOO"),
# rounding errors
("de", Decimal("1.234"), "EUR", "1,23" + NBSP + "€"),
("de", Decimal("1023.1"), "JPY", "JPY 1.023,10"),
# deal with precision that is higher than the currency
("de", Decimal("1.234"), "EUR", "1,234" + NBSP + "€"),
("de", 1.234, "EUR", "1,234" + NBSP + "€"),
("de", Decimal("1.2340"), "EUR", "1,234" + NBSP + "€"),
("de", Decimal("1.2300"), "EUR", "1,23" + NBSP + "€"),
("de", Decimal("1023.1"), "JPY", "1.023,10" + NBSP + "¥"),
]
)
def test_money_filter(locale, amount, currency, expected):
@@ -98,9 +103,31 @@ def test_money_filter(locale, amount, currency, expected):
[
("de", Decimal("1000.00"), "EUR", "1.000,00"),
("en", Decimal("1000.00"), "EUR", "1,000.00"),
("de", Decimal("1023.1"), "JPY", "1.023,10"),
("de", Decimal("1023.1"), "JPY", "1.023,1"),
]
)
def test_money_filter_hidecurrency(locale, amount, currency, expected):
translation.activate(locale)
assert money_filter(amount, currency, hide_currency=True) == expected
@pytest.mark.parametrize(
"locale,rate,expected",
[
("de", Decimal("2.00"), "2"),
("de", Decimal("2.50"), "2,5"),
("de", Decimal("2.2340"), "2,234"),
("en", Decimal("2.00"), "2"),
("en", Decimal("2.50"), "2.5"),
("en", Decimal("4.3e7"), "43000000"),
("en", Decimal("2.2340"), "2.234"),
("en", "2.23", "2.23"),
("en", 2.23, "2.23"),
("en", 2, "2"),
("en", "", ""),
("en", None, "0"),
]
)
def test_tax_rate_format(locale, rate, expected):
translation.activate(locale)
assert tax_rate_format(rate) == expected
+1 -1
View File
@@ -32,7 +32,7 @@ def env():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
+41
View File
@@ -2438,6 +2438,47 @@ def test_refund_paid_order_offsetting_to_wrong_currency(client, env):
assert b'different currency' in r.content
@pytest.mark.django_db
def test_refund_paid_order_offsetting_to_wrong_permissions(client, env):
with scopes_disabled():
p = env[2].payments.last()
p.confirm()
t = env[0].organizer.teams.get()
t.all_events = False
t.save()
t.limit_events.set([env[0]])
client.login(email='dummy@dummy.dummy', password='dummy')
event2 = Event.objects.create(
organizer=env[0].organizer, name='Dummy', slug='dummy2',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy',
currency='EUR',
)
ticket2 = Item.objects.create(event=event2, name='Early-bird ticket',
category=None, default_price=23,
admission=True, personalized=True)
o = Order.objects.create(
code='BAZ', event=event2, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
sales_channel=event2.organizer.sales_channels.get(identifier="web"),
total=5, locale='en'
)
o.positions.create(price=5, item=ticket2)
r = client.post('/control/event/dummy/dummy/orders/ABC32/refund', {
'start-partial_amount': '5.00',
'start-mode': 'partial',
'start-action': 'mark_pending',
'refund-offsetting': '5.00',
'order-offsetting': 'BAZ',
'manual_state': 'pending',
'last_known_refund_id': 0,
'perform': 'on'
}, follow=True)
assert b'alert-danger' in r.content
assert b'do not have access' in r.content
@pytest.mark.django_db
def test_refund_paid_order_offsetting(client, env):
with scopes_disabled():
+13 -13
View File
@@ -349,25 +349,25 @@ class UserSettings2FATest(SoupTest):
def test_delete_u2f(self):
d = U2FDevice.objects.create(user=self.user, name='Test')
self.client.get('/control/settings/2fa/u2f/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/u2f/{}/delete'.format(d.pk))
self.client.get('/control/settings/2fa/pretixbase.u2fdevice/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/pretixbase.u2fdevice/{}/delete'.format(d.pk))
assert not U2FDevice.objects.exists()
def test_delete_webauthn(self):
d = WebAuthnDevice.objects.create(user=self.user, name='Test')
self.client.get('/control/settings/2fa/webauthn/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/webauthn/{}/delete'.format(d.pk))
self.client.get('/control/settings/2fa/pretixbase.webauthndevice/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/pretixbase.webauthndevice/{}/delete'.format(d.pk))
assert not WebAuthnDevice.objects.exists()
def test_delete_totp(self):
d = TOTPDevice.objects.create(user=self.user, name='Test')
self.client.get('/control/settings/2fa/totp/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/totp/{}/delete'.format(d.pk))
self.client.get('/control/settings/2fa/otp_totp.totpdevice/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/otp_totp.totpdevice/{}/delete'.format(d.pk))
assert not TOTPDevice.objects.exists()
def test_create_webauthn_require_https(self):
r = self.client.post('/control/settings/2fa/add', {
'devicetype': 'webauthn',
'devicetype': 'pretixbase.webauthndevice',
'name': 'Foo'
})
assert 'alert-danger' in r.content.decode()
@@ -376,7 +376,7 @@ class UserSettings2FATest(SoupTest):
with mocker_context() as mocker:
mocker.patch('django.http.request.HttpRequest.is_secure')
self.client.post('/control/settings/2fa/add', {
'devicetype': 'webauthn',
'devicetype': 'pretixbase.webauthndevice',
'name': 'Foo'
})
d = WebAuthnDevice.objects.first()
@@ -385,7 +385,7 @@ class UserSettings2FATest(SoupTest):
def test_create_totp(self):
self.client.post('/control/settings/2fa/add', {
'devicetype': 'totp',
'devicetype': 'otp_totp.totpdevice',
'name': 'Foo'
})
d = TOTPDevice.objects.first()
@@ -393,7 +393,7 @@ class UserSettings2FATest(SoupTest):
def test_confirm_totp(self):
self.client.post('/control/settings/2fa/add', {
'devicetype': 'totp',
'devicetype': 'otp_totp.totpdevice',
'name': 'Foo'
}, follow=True)
d = TOTPDevice.objects.first()
@@ -411,7 +411,7 @@ class UserSettings2FATest(SoupTest):
def test_confirm_totp_failed(self):
self.client.post('/control/settings/2fa/add', {
'devicetype': 'totp',
'devicetype': 'otp_totp.totpdevice',
'name': 'Foo'
}, follow=True)
d = TOTPDevice.objects.first()
@@ -428,7 +428,7 @@ class UserSettings2FATest(SoupTest):
with mocker_context() as mocker:
mocker.patch('django.http.request.HttpRequest.is_secure')
self.client.post('/control/settings/2fa/add', {
'devicetype': 'webauthn',
'devicetype': 'pretixbase.webauthndevice',
'name': 'Foo'
}, follow=True)
d = WebAuthnDevice.objects.first()
@@ -443,7 +443,7 @@ class UserSettings2FATest(SoupTest):
with mocker_context() as mocker:
mocker.patch('django.http.request.HttpRequest.is_secure')
self.client.post('/control/settings/2fa/add', {
'devicetype': 'webauthn',
'devicetype': 'pretixbase.webauthndevice',
'name': 'Foo'
}, follow=True)
+6 -6
View File
@@ -37,26 +37,26 @@ def event():
@pytest.mark.django_db
def test_require_plugin(event, client):
event.plugins = 'pretix.plugins.paypal'
event.plugins = 'pretix.plugins.paypal2'
event.live = True
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
r = client.get('/mrmcd/2015/paypal2/abort/', follow=False)
assert r.status_code == 302
event.plugins = ''
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
r = client.get('/mrmcd/2015/paypal2/abort/', follow=False)
assert r.status_code == 404
@pytest.mark.django_db
def test_require_live(event, client):
event.plugins = 'pretix.plugins.paypal'
event.plugins = 'pretix.plugins.paypal2'
event.live = True
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
r = client.get('/mrmcd/2015/paypal2/abort/', follow=False)
assert r.status_code == 302
event.live = False
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
r = client.get('/mrmcd/2015/paypal2/abort/', follow=False)
assert r.status_code == 403
@@ -56,7 +56,7 @@ def env():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
event.settings.invoice_numbers_prefix = 'INV-'
event.settings.invoice_numbers_counter_length = 3
@@ -843,7 +843,7 @@ def test_event_name_prefix_contains_dash(env, orga_job):
for slug in slugs:
event = Event.objects.create(
organizer=event.organizer, name=slug.upper(), slug=slug,
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
with scopes_disabled():
o1.event = Event.objects.get(slug="dummy2345")
@@ -871,7 +871,7 @@ def test_event_name_prefix_multiple_dashes(env, orga_job):
for slug in slugs:
event = Event.objects.create(
organizer=event.organizer, name=slug.upper(), slug=slug,
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
with scopes_disabled():
@@ -37,7 +37,7 @@ def env():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
@@ -38,7 +38,7 @@ def env():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
@@ -69,7 +69,7 @@ def env():
def refund_huf(env):
event = Event.objects.create(
organizer=env[0].organizer, name='Dummy', slug='dummy2', currency='HUF',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
order = Order.objects.create(
code='HUFFY', event=event, email='admin@localhost',
-33
View File
@@ -1,33 +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.
-93
View File
@@ -1,93 +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.
import datetime
import pytest
from django.utils.timezone import now
from pretix.base.models import (
CartPosition, Event, Item, ItemCategory, Organizer, Quota,
)
from pretix.testutils.sessions import add_cart_session, get_cart_session_key
@pytest.fixture
def env(client):
orga = Organizer.objects.create(name='CCC', slug='ccc')
event = Event.objects.create(
organizer=orga, name='30C3', slug='30c3',
date_from=datetime.datetime(now().year + 1, 12, 26, tzinfo=datetime.timezone.utc),
plugins='pretix.plugins.paypal',
live=True
)
category = ItemCategory.objects.create(event=event, name="Everything", position=0)
quota_tickets = Quota.objects.create(event=event, name='Tickets', size=5)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
category=category, default_price=23, admission=True)
quota_tickets.items.add(ticket)
event.settings.set('attendee_names_asked', False)
event.settings.set('payment_paypal__enabled', True)
event.settings.set('payment_paypal__fee_abs', 3)
event.settings.set('payment_paypal_endpoint', 'sandbox')
event.settings.set('payment_paypal_client_id', '12345')
event.settings.set('payment_paypal_secret', '12345')
add_cart_session(client, event, {'email': 'admin@localhost'})
return client, ticket
@pytest.mark.django_db
def test_payment(env, monkeypatch):
def create_payment(self, request, payment):
assert payment['intent'] == 'sale'
assert payment['transactions'][0]['amount']['currency'] == 'EUR'
assert payment['transactions'][0]['amount']['total'] == '26.00'
create_payment.called = True
return 'https://approve.url'
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal._create_payment", create_payment)
client, ticket = env
session_key = get_cart_session_key(client, ticket.event)
CartPosition.objects.create(
event=ticket.event, cart_id=session_key, item=ticket,
price=23, expires=now() + datetime.timedelta(minutes=10)
)
client.get('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), follow=True)
client.post('/%s/%s/checkout/questions/' % (ticket.event.organizer.slug, ticket.event.slug), {
'email': 'admin@localhost'
}, follow=True)
response = client.post('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), {
'payment': 'paypal'
})
assert response['Location'] == 'https://approve.url'
-67
View File
@@ -1,67 +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.
import datetime
import pytest
from pretix.base.models import Event, Organizer, Team, User
@pytest.fixture
def env(client):
orga = Organizer.objects.create(name='CCC', slug='ccc')
event = Event.objects.create(
organizer=orga, name='30C3', slug='30c3',
date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc),
plugins='pretix.plugins.paypal',
live=True
)
event.settings.set('attendee_names_asked', False)
event.settings.set('payment_paypal__enabled', True)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
t.members.add(user)
t.limit_events.add(event)
client.force_login(user)
return client, event
@pytest.mark.django_db
def test_settings(env):
client, event = env
response = client.get('/control/event/%s/%s/settings/payment/paypal' % (event.organizer.slug, event.slug),
follow=True)
assert response.status_code == 200
assert 'paypal__enabled' in response.rendered_content
-391
View File
@@ -1,391 +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 json
from datetime import timedelta
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import (
Event, Order, OrderPayment, OrderRefund, Organizer, Team, User,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
@pytest.fixture
def env():
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy', plugins='pretix.plugins.paypal',
date_from=now(), live=True
)
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
t.members.add(user)
t.limit_events.add(event)
o1 = Order.objects.create(
code='FOOBAR', event=event, email='dummy@dummy.test',
status=Order.STATUS_PAID,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('13.37'),
sales_channel=o.sales_channels.get(identifier="web"),
)
o1.payments.create(
amount=o1.total,
provider='paypal',
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
info=json.dumps({
"id": "PAY-5YK922393D847794YKER7MUI",
"create_time": "2013-02-19T22:01:53Z",
"update_time": "2013-02-19T22:01:55Z",
"state": "approved",
"intent": "sale",
"payer": {
"payment_method": "credit_card",
"funding_instruments": [
{
"credit_card": {
"type": "mastercard",
"number": "xxxxxxxxxxxx5559",
"expire_month": 2,
"expire_year": 2018,
"first_name": "Betsy",
"last_name": "Buyer"
}
}
]
},
"transactions": [
{
"amount": {
"total": "7.47",
"currency": "USD",
"details": {
"subtotal": "7.47"
}
},
"description": "This is the payment transaction description.",
"note_to_payer": "Contact us for any questions on your order.",
"related_resources": [
{
"sale": {
"id": "36C38912MN9658832",
"create_time": "2013-02-19T22:01:53Z",
"update_time": "2013-02-19T22:01:55Z",
"state": "completed",
"amount": {
"total": "7.47",
"currency": "USD"
},
"protection_eligibility": "ELIGIBLE",
"protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE",
"transaction_fee": {
"value": "1.75",
"currency": "USD"
},
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"links": [
{
"href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund",
"rel": "refund",
"method": "POST"
},
{
"href":
"https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI",
"rel": "parent_payment",
"method": "GET"
}
]
}
}
]
}
],
"links": [
{
"href": "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI",
"rel": "self",
"method": "GET"
}
]
})
)
return event, o1
def get_test_charge(order: Order):
return {
"id": "36C38912MN9658832",
"create_time": "2013-02-19T22:01:53Z",
"update_time": "2013-02-19T22:01:55Z",
"state": "completed",
"amount": {
"total": "7.47",
"currency": "USD"
},
"protection_eligibility": "ELIGIBLE",
"protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE",
"transaction_fee": {
"value": "1.75",
"currency": "USD"
},
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"links": [
{
"href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund",
"rel": "refund",
"method": "POST"
},
{
"href": "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI",
"rel": "parent_payment",
"method": "GET"
}
]
}
def get_test_refund(order: Order):
return {
'refund_from_received_amount': {'value': '13.30', 'currency': 'EUR'},
'amount': {'total': '13.37', 'currency': 'EUR'},
'sale_id': '1G495778AR8401726',
'update_time': '2018-07-24T07:50:07Z',
'total_refunded_amount': {'value': '13.37', 'currency': 'EUR'},
'refund_reason_code': 'REFUND',
'invoice_number': 'Test',
'parent_payment': 'PAY-0UB50445HE155450FLNLNMUY',
'state': 'completed',
'create_time': '2018-07-24T07:50:07Z',
'refund_from_transaction_fee': {'value': '0.07', 'currency': 'EUR'},
'id': '93M41501U3542574L',
'refund_to_payer': {'value': '13.37', 'currency': 'EUR'},
'links': [
{'method': 'GET', 'rel': 'self',
'href': 'https://api.sandbox.paypal.com/v1/payments/refund/93M41501U3542574L'},
{'method': 'GET',
'rel': 'parent_payment',
'href': 'https://api.sandbox.paypal.com/v1/payments/payment/PAY-0UB50445HE155450FLNLNMUY'},
{'method': 'GET', 'rel': 'sale',
'href': 'https://api.sandbox.paypal.com/v1/payments/sale/1G495778AR8401726'}
]
}
@pytest.mark.django_db
def test_webhook_all_good(env, client, monkeypatch):
charge = get_test_charge(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
client.post('/dummy/dummy/paypal/webhook/', json.dumps(
{
"id": "WH-2WR32451HC0233532-67976317FL4543714",
"create_time": "2014-10-23T17:23:52Z",
"resource_type": "sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"summary": "A successful sale payment was made for $ 0.48 USD",
"resource": {
"amount": {
"total": "-0.01",
"currency": "USD"
},
"id": "36C38912MN9658832",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2014-10-31T15:41:51Z",
"state": "completed",
"create_time": "2014-10-31T15:41:51Z",
"links": [],
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_mark_paid(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
with scopes_disabled():
order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING)
charge = get_test_charge(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI")
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-2WR32451HC0233532-67976317FL4543714",
"create_time": "2014-10-23T17:23:52Z",
"resource_type": "sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"summary": "A successful sale payment was made for $ 0.48 USD",
"resource": {
"amount": {
"total": "-0.01",
"currency": "USD"
},
"id": "36C38912MN9658832",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2014-10-31T15:41:51Z",
"state": "completed",
"create_time": "2014-10-31T15:41:51Z",
"links": [],
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_refund1(env, client, monkeypatch):
order = env[1]
charge = get_test_charge(env[1])
charge['state'] = 'refunded'
refund = get_test_refund(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI")
client.post('/_paypal/webhook/', json.dumps(
{
# Sample obtained in a sandbox webhook
"id": "WH-9K829080KA1622327-31011919VC6498738",
"create_time": "2017-01-15T20:15:36Z",
"resource_type": "refund",
"event_type": "PAYMENT.SALE.REFUNDED",
"summary": "A EUR 255.41 EUR sale payment was refunded",
"resource": {
"amount": {
"total": "255.41",
"currency": "EUR"
},
"id": "75S46770PP192124D",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2017-01-15T20:15:06Z",
"create_time": "2017-01-15T20:14:29Z",
"state": "completed",
"links": [],
"refund_to_payer": {
"value": "255.41",
"currency": "EUR"
},
"invoice_number": "",
"refund_reason_code": "REFUND",
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
with scopes_disabled():
r = order.refunds.first()
assert r.provider == 'paypal'
assert r.amount == order.total
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
@pytest.mark.django_db
def test_webhook_refund2(env, client, monkeypatch):
order = env[1]
charge = get_test_charge(env[1])
charge['state'] = 'refunded'
refund = get_test_refund(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI")
client.post('/_paypal/webhook/', json.dumps(
{
# Sample obtained in the webhook simulator
"id": "WH-2N242548W9943490U-1JU23391CS4765624",
"create_time": "2014-10-31T15:42:24Z",
"resource_type": "refund",
"event_type": "PAYMENT.SALE.REFUNDED",
"summary": "A 0.01 USD sale payment was refunded",
"resource": {
"amount": {
"total": "-0.01",
"currency": "USD"
},
"id": "36C38912MN9658832",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2014-10-31T15:41:51Z",
"state": "completed",
"create_time": "2014-10-31T15:41:51Z",
"links": [],
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
with scopes_disabled():
r = order.refunds.first()
assert r.provider == 'paypal'
assert r.amount == order.total
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
+1 -1
View File
@@ -32,7 +32,7 @@ from paypalhttp.http_response import Result
from pretix.base.models import (
Event, Order, OrderPayment, OrderRefund, Organizer, Team, User,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal2.models import ReferencedPayPalObject
@pytest.fixture
+52
View File
@@ -478,6 +478,58 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
assert ia.vat_id == "AT123456"
assert not ia.vat_id_validated
def test_vat_id_revalidated_after_country_change(self):
self.tr19.eu_reverse_charge = True
self.tr19.keep_gross_if_rate_changes = True
self.tr19.home_country = Country('DE')
self.tr19.save()
self.event.settings.invoice_address_vatid = True
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
with mock.patch('pretix.base.services.tax._validate_vat_id_EU') as mock_validate:
mock_validate.return_value = 'AT123456'
self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
assert cr1.price == Decimal('23.00')
assert cr1.tax_rate == Decimal('0.00')
assert cr1.tax_value == Decimal('0.00')
with mock.patch('pretix.base.services.tax._validate_vat_id_EU') as mock_validate:
def raiser(*args, **kwargs):
raise VATIDFinalError('country mismatch')
mock_validate.side_effect = raiser
r = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '1012 WX',
'city': 'Here',
'country': 'NL',
'vat_id': 'AT123456',
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
assert b'alert-danger' in r.content
def test_reverse_charge_keep_gross(self):
self.tr19.eu_reverse_charge = True
self.tr19.keep_gross_if_rate_changes = True
+29
View File
@@ -744,6 +744,35 @@ class ItemDisplayTest(EventTestMixin, SoupTest):
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
self.assertIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text)
def test_hidden_if_item_available_variation_unavailable_by_time(self):
with scopes_disabled():
q = Quota.objects.create(event=self.event, name='Early-bird', size=10)
q2 = Quota.objects.create(event=self.event, name='Late-bird', size=10)
item_with_vars = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12)
v = item_with_vars.variations.create(
value='Regular', active=True,
)
item2 = Item.objects.create(event=self.event, name='Late-bird ticket', default_price=12,
hidden_if_item_available=item_with_vars)
q.items.add(item_with_vars)
q.variations.add(v)
q2.items.add(item2)
self.event.settings.hide_sold_out = True
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
self.assertIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text)
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
self.assertNotIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text)
item_with_vars.available_until = now() - datetime.timedelta(days=3)
item_with_vars.available_until_mode = "hide"
item_with_vars.save()
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
self.assertNotIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text)
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
self.assertIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text)
def test_bundle_sold_out(self):
with scopes_disabled():
q = Quota.objects.create(event=self.event, name='Quota', size=2)